← back to Wallco Ai
curator: generalize to any collection via ?category= (loads drunk-animals etc) + 'Auto-ID all visible' batch button (capped 60, 4-concurrent); drunk-aware auto-ID detects 'No bottle' + 'Not animals' (off-concept florals/damask); box labels→defect_tags so chips reflect confirmed boxes; header count uses category
1dbe891286dfda73d50d74cde6e1ff0a39951c40 · 2026-05-27 10:20:02 -0700 · Steve Abrams
Files touched
M public/admin/cactus-curator.htmlM server.js
Diff
commit 1dbe891286dfda73d50d74cde6e1ff0a39951c40
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 10:20:02 2026 -0700
curator: generalize to any collection via ?category= (loads drunk-animals etc) + 'Auto-ID all visible' batch button (capped 60, 4-concurrent); drunk-aware auto-ID detects 'No bottle' + 'Not animals' (off-concept florals/damask); box labels→defect_tags so chips reflect confirmed boxes; header count uses category
---
public/admin/cactus-curator.html | 43 ++++++++++++++++++++++++++++++++++------
server.js | 41 +++++++++++++++++++++++++++++---------
2 files changed, 69 insertions(+), 15 deletions(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 60e930c..b27519f 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -57,6 +57,10 @@
background:#ff3b30; color:#fff; border-radius:50%; font:800 12px sans-serif; cursor:pointer; z-index:6; }
.bad-box .lbl { position:absolute; bottom:-1px; left:-1px; background:#ff3b30; color:#fff; font:800 9px sans-serif; padding:1px 5px; border-radius:0 3px 0 0; }
body.shifting .thumb { cursor:crosshair; }
+ .ctl-btn { padding:6px 12px; border:1px solid #6a5a3a; border-radius:6px; background:#221e16; color:#ffd68a;
+ font:700 12px sans-serif; cursor:pointer; }
+ .ctl-btn:hover { background:#ffb300; color:#1a1a1a; }
+ .ctl-btn:disabled { opacity:.6; cursor:wait; }
/* candidate (auto-detected, unconfirmed) boxes — amber dashed with confirm/delete */
.bad-box.cand { border-color:#ffb300; background:rgba(255,179,0,.13); }
.bad-box.cand .lbl { background:#ffb300; color:#1a1a1a; }
@@ -149,6 +153,7 @@
<label class="ctl">Density
<input type="range" id="density" min="2" max="9" step="1" value="5">
</label>
+ <button id="autoall" class="ctl-btn" title="auto-detect bad areas on every visible card">🔍 Auto-ID all visible</button>
<span class="legend"><b>drag</b> across images to select · <b>shift+drag on an image</b> = ▢ box a bad area (red) · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
</div>
</header>
@@ -170,6 +175,8 @@
<script>
const ADMIN = new URLSearchParams(location.search).get('admin');
+// Collection is a URL param — this curator now serves cactus, drunk-animals, etc.
+const CATEGORY = (new URLSearchParams(location.search).get('category') || 'cactus');
const q = s => ADMIN ? (s + (s.includes('?')?'&':'?') + 'admin=' + encodeURIComponent(ADMIN)) : s;
const $ = id => document.getElementById(id);
const grid = $('grid');
@@ -190,6 +197,25 @@ $('sort').value = LS.s;
$('filter').value = LS.f;
$('density').addEventListener('input', e => { LS.d = e.target.value; applyDensity(e.target.value); });
+// Auto-ID every visible card (capped, limited concurrency so we don't hammer Gemini).
+$('autoall').addEventListener('click', async () => {
+ const cards = [...grid.querySelectorAll('.card')];
+ if (!cards.length) return;
+ const CAP = 60, todo = cards.slice(0, CAP);
+ if (!confirm(`Auto-ID ${todo.length} visible card(s) via Gemini vision (~$${(todo.length*0.003).toFixed(2)})`
+ + (cards.length>CAP ? ` — capped at ${CAP} of ${cards.length}; narrow the filter for the rest.` : '?'))) return;
+ const btn = $('autoall'), orig = btn.textContent; btn.disabled = true;
+ let done = 0, idx = 0;
+ const worker = async () => {
+ while (idx < todo.length) {
+ const el = todo[idx++], id = parseInt(el.dataset.id, 10), d = ALL.find(x => x.id === id);
+ if (d) { try { await autoId(d, el, el.querySelector('.auto-id-btn')); } catch {} }
+ btn.textContent = `… ${++done}/${todo.length}`;
+ }
+ };
+ await Promise.all(Array.from({ length: 4 }, worker));
+ btn.textContent = orig; btn.disabled = false;
+});
$('sort').addEventListener('change', e => { LS.s = e.target.value; render(); });
$('filter').addEventListener('change', e => { LS.f = e.target.value; render(); });
@@ -231,7 +257,8 @@ function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?
// Map a bad_examples defect_tag → short human label for the red chip.
const TAG_LABEL = { seams:'Seams', repeat_break_lr:'Seams', repeat_break_tb:'Seams',
edges_fail:'Seams', poor_edges:'Poor edges', cartoonish:'Cartoonish', blurry:'Blurry',
- ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged' };
+ ghost:'Ghost', bleed:'Bleed', heal_derivative:'Healed', manual_bad_region:'Flagged',
+ no_bottle:'No bottle', not_animals:'Not animals' };
function failReason(d){
const reasons = [];
const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
@@ -368,7 +395,7 @@ function renderBoxes(d, el){
// Auto-detect candidate bad areas via Gemini vision → render as amber pending boxes.
function autoId(d, el, btn){
if (btn) { btn.classList.add('busy'); btn.textContent = '… scanning'; }
- fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
+ return fetch(q(`/api/admin/cactus/${d.id}/auto-id`))
.then(r => r.json())
.then(j => {
if (!j.ok) { alert('Auto-ID failed: ' + (j.error||'unknown')); return; }
@@ -380,8 +407,10 @@ function autoId(d, el, btn){
.finally(() => { if (btn && d.candidates && d.candidates.length) { btn.classList.remove('busy'); btn.textContent = '🔍 Auto-ID'; }
else if (btn) btn.classList.remove('busy'); });
}
-// Defect-type picker shown after drawing a bad-area box.
-const BOX_LABELS = ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
+// Defect-type picker shown after drawing a bad-area box (collection-aware).
+const BOX_LABELS = /drunk/i.test(CATEGORY)
+ ? ['No bottle','Not animals','Poor edges','Cartoonish','Seams','Blurry']
+ : ['Seams','Poor edges','Cartoonish','Blurry','Ghost','Bleed'];
let _picker = null;
function closePicker(){ if(_picker){ document.removeEventListener('mousedown', _picker._out, true); _picker.remove(); _picker=null; } }
function pickLabel(clientX, clientY){
@@ -425,7 +454,7 @@ function render(){
function updateStat(){
const total = ALL.length;
const done = ALL.filter(d => decided.get(d.id) || d.user_removed || d.needs_fixing_at || d.digital_file_at || d.web_viewer).length;
- $('stat').innerHTML = `<b>${total}</b> cactus · <b>${done}</b> decided · <b>${total-done}</b> left`;
+ $('stat').innerHTML = `<b>${total}</b> ${CATEGORY} · <b>${done}</b> decided · <b>${total-done}</b> left`;
}
function toggleSel(id, el){
@@ -610,10 +639,12 @@ function applySelection(next){
async function load(){
try {
- const r = await fetch(q('/api/admin/cactus/list?include=removed'));
+ const r = await fetch(q('/api/admin/cactus/list?include=removed&category=' + encodeURIComponent(CATEGORY)));
if(!r.ok) throw new Error('list '+r.status+(r.status===404?' (admin gate — append ?admin=TOKEN)':''));
const j = await r.json();
ALL = j.items || [];
+ // Reflect the loaded collection in the title.
+ const h = document.querySelector('h1'); if(h && CATEGORY!=='cactus') h.textContent = '❖ ' + CATEGORY + ' Curator';
render();
} catch(e){ $('err').style.display='block'; $('err').textContent = 'Failed to load: '+e.message; }
}
diff --git a/server.js b/server.js
index bc777e1..8fd5d52 100644
--- a/server.js
+++ b/server.js
@@ -1177,6 +1177,9 @@ app.get('/api/admin/defect-registry/list', (req, res) => {
app.get('/api/admin/cactus/list', (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const includeRemoved = String(req.query.include || '') === 'removed';
+ // Category is now a param (default cactus) so this curator serves any collection
+ // — cactus, drunk-animals, etc. Sanitized to a safe ILIKE fragment.
+ const cat = (String(req.query.category || 'cactus').replace(/[^a-zA-Z0-9 ._·-]/g, '').slice(0, 40)) || 'cactus';
try {
const rawJson = psqlQuery(
"SELECT COALESCE(json_agg(t ORDER BY t.rank_score DESC NULLS LAST, t.id), '[]'::json) FROM (" +
@@ -1197,7 +1200,7 @@ app.get('/api/admin/cactus/list', (req, res) => {
"FROM all_designs d " +
"LEFT JOIN wallco_cactus_rank r ON r.design_id = d.id " +
"LEFT JOIN bad_examples be ON be.design_id = d.id " +
- "WHERE d.category ILIKE '%cactus%' " +
+ "WHERE d.category ILIKE '%" + cat + "%' " +
// Exclude seam-healer derivatives (midheal_/edgeheal_/smartfix_): they all
// carry the old destructive blur (horizontal/vertical seam line + fuzzy
// edges) and should never be curated — curate the clean round-1 originals.
@@ -1339,16 +1342,22 @@ app.post('/api/admin/cactus/:id/annotations', express.json({ limit: '32kb' }), (
return res.json({ ok: true, id, boxes: [] });
}
const bj = JSON.stringify(boxes).replace(/'/g, "''");
+ // Box labels → defect tags so the red chips reflect what was boxed.
+ const LABEL_TAG = { 'Seams':'seams','Poor edges':'poor_edges','Cartoonish':'cartoonish','Blurry':'blurry',
+ 'Ghost':'ghost','Bleed':'bleed','No bottle':'no_bottle','Not animals':'not_animals' };
+ const tagSet = new Set(['manual_bad_region']);
+ boxes.forEach(b => { const t = LABEL_TAG[b.label]; if (t) tagSet.add(t); });
+ const tagsSql = 'ARRAY[' + [...tagSet].map(t => `'${t}'`).join(',') + ']::text[]';
psqlExecLocal(
`INSERT INTO bad_examples (design_id, defect_tags, category, prompt, negative_prompt, seed, generator,
width_in, height_in, dominant_hex, parent_design_id, local_path, boxes, source, design_created_at)
- SELECT s.id, ARRAY['manual_bad_region']::text[], s.category, s.prompt, s.negative_prompt, s.seed, s.generator,
+ SELECT s.id, ${tagsSql}, s.category, s.prompt, s.negative_prompt, s.seed, s.generator,
s.width_in, s.height_in, s.dominant_hex, s.parent_design_id, s.local_path, '${bj}'::jsonb,
'curator-annotation', s.created_at
FROM spoon_all_designs s WHERE s.id=${id}
ON CONFLICT (design_id) DO UPDATE SET
boxes='${bj}'::jsonb,
- defect_tags=(SELECT array_agg(DISTINCT t) FROM unnest(bad_examples.defect_tags || ARRAY['manual_bad_region']) t),
+ defect_tags=(SELECT array_agg(DISTINCT t) FROM unnest(bad_examples.defect_tags || ${tagsSql}) t),
source='curator-annotation', recorded_at=now();`);
res.json({ ok: true, id, boxes });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
@@ -1366,14 +1375,28 @@ app.get('/api/admin/cactus/:id/auto-id', async (req, res) => {
const KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!KEY) return res.status(503).json({ ok: false, error: 'GEMINI_API_KEY not configured' });
try {
- const lp = (psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1;`) || '').trim();
+ let lp = '', category = '';
+ try { const r = JSON.parse(psqlQuery(`SELECT row_to_json(t) FROM (SELECT local_path, category FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`));
+ lp = (r.local_path || '').trim(); category = r.category || ''; } catch {}
if (!lp || !fs.existsSync(lp)) return res.status(404).json({ ok: false, error: 'image not on disk' });
const b64 = fs.readFileSync(lp).toString('base64');
- const prompt = "This is a wallpaper tile. Find DEFECTIVE AREAS and return bounding boxes. Labels: "
- + "'Seams' (motif sliced at a repeat seam), 'Poor edges' (wobbly/rough/imprecise silhouette edges), "
- + "'Cartoonish' (flat clip-art/childish style, not refined), 'Blurry', 'Ghost' (faded duplicate), 'Bleed'. "
- + "Return ONLY a JSON array, up to 6: [{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] "
- + "as fractions of the image, origin top-left. Return [] if the tile is clean.";
+ // Category-aware defect prompt. Drunk-* collections have a HARD spec: every
+ // animal must visibly hold an alcoholic drink (bottle/cocktail) + ideally a
+ // lit joint — so "no bottle" and "not even animals" (florals/damask slipped
+ // into the collection) are the defects to box.
+ const isDrunk = /drunk/i.test(category);
+ const prompt = isDrunk
+ ? "This is a 'DRUNK ANIMALS' novelty wallpaper. HARD RULE: every animal must visibly hold/have an "
+ + "alcoholic drink (bottle, cocktail, glass) AND ideally a lit joint. Box the DEFECTS. Labels: "
+ + "'No bottle' (an animal with NO visible drink/bottle), 'Not animals' (region/tile has NO animals at "
+ + "all — e.g. florals, damask, geometric — wrong for this collection), 'Poor edges', 'Cartoonish', 'Seams'. "
+ + "Box each offending animal or the whole tile if it's off-concept. Return ONLY a JSON array up to 8 "
+ + "[{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] fractions, origin top-left. [] if every animal has a drink."
+ : "This is a wallpaper tile. Find DEFECTIVE AREAS and return bounding boxes. Labels: "
+ + "'Seams' (motif sliced at a repeat seam), 'Poor edges' (wobbly/rough/imprecise silhouette edges), "
+ + "'Cartoonish' (flat clip-art/childish style, not refined), 'Blurry', 'Ghost' (faded duplicate), 'Bleed'. "
+ + "Return ONLY a JSON array, up to 6: [{\"x\":0..1,\"y\":0..1,\"w\":0..1,\"h\":0..1,\"label\":\"...\"}] "
+ + "as fractions of the image, origin top-left. Return [] if the tile is clean.";
const body = { contents: [{ parts: [
{ inline_data: { mime_type: 'image/png', data: b64 } }, { text: prompt },
] }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } };
← 86b2fdf cactus-curator: AUTO-ID bad areas via Gemini vision (candida
·
back to Wallco Ai
·
Validate cactus seamless redo: circular-padding + gate = 4/4 99be6d6 →