← back to Wallco Ai
cactus-curator: AUTO-ID bad areas via Gemini vision (candidate amber boxes) + human confirm(✓)/reject(✗)/add(shift-drag) flow with defect-label picker (Seams/Poor edges/Cartoonish/Blurry/Ghost/Bleed); confirmed boxes+labels persist to bad_examples. /api/admin/cactus/:id/auto-id endpoint (Claude path blocked — Anthropic acct out of credits, Gemini suffices since human reviews)
86b2fdfb672419c721267cce98e8f106bc881500 · 2026-05-27 10:09:45 -0700 · Steve Abrams
Files touched
M public/admin/cactus-curator.htmlM server.js
Diff
commit 86b2fdfb672419c721267cce98e8f106bc881500
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 10:09:45 2026 -0700
cactus-curator: AUTO-ID bad areas via Gemini vision (candidate amber boxes) + human confirm(✓)/reject(✗)/add(shift-drag) flow with defect-label picker (Seams/Poor edges/Cartoonish/Blurry/Ghost/Bleed); confirmed boxes+labels persist to bad_examples. /api/admin/cactus/:id/auto-id endpoint (Claude path blocked — Anthropic acct out of credits, Gemini suffices since human reviews)
---
public/admin/cactus-curator.html | 87 +++++++++++++++++++++++++++++++++++-----
server.js | 37 +++++++++++++++++
2 files changed, 114 insertions(+), 10 deletions(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index adf89cf..60e930c 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -57,6 +57,22 @@
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; }
+ /* 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; }
+ .bad-box .ok { position:absolute; top:-9px; right:11px; width:17px; height:17px; line-height:15px; text-align:center;
+ background:#2e7d32; color:#fff; border-radius:50%; font:800 11px sans-serif; cursor:pointer; z-index:6; }
+ /* defect-type picker after drawing a box */
+ .label-picker { position:fixed; z-index:60; background:#17150f; border:1px solid #4a4636; border-radius:9px; padding:8px;
+ display:flex; flex-direction:column; gap:5px; box-shadow:0 10px 30px rgba(0,0,0,.55); min-width:152px; }
+ .label-picker .lp-h { font:800 10px var(--sans,sans-serif); color:#8a7a5e; text-transform:uppercase; letter-spacing:.08em; margin:0 2px 3px; }
+ .label-picker button { padding:7px 10px; border:0; border-radius:5px; background:#2a2620; color:#ece4d4; font:700 12.5px sans-serif; cursor:pointer; text-align:left; }
+ .label-picker button:hover { background:#e11d2a; color:#fff; }
+ .label-picker .lp-cancel { background:transparent; color:#8a7a5e; font-size:11px; font-weight:600; }
+ .auto-id-btn { position:absolute; bottom:6px; left:6px; z-index:4; background:rgba(0,0,0,.6); color:#ffd68a;
+ border:1px solid #6a5a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px; cursor:pointer; }
+ .auto-id-btn:hover { background:#ffb300; color:#1a1a1a; }
+ .auto-id-btn.busy { opacity:.5; cursor:wait; }
/* marquee rubber-band selection */
#marquee { position:fixed; z-index:40; border:1.5px solid #4aa3ff; background:rgba(74,163,255,.16); pointer-events:none; display:none; border-radius:3px; }
body.dragging { user-select:none; cursor:crosshair; }
@@ -263,6 +279,7 @@ function card(d){
</div>
<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
${seamCallout}
+ <button class="auto-id-btn" title="auto-detect bad areas (you confirm/delete)">🔍 Auto-ID</button>
<div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
</div>
<div class="meta">
@@ -306,32 +323,82 @@ function card(d){
cur = { x:Math.min(sx,cx), y:Math.min(sy,cy), w:Math.abs(cx-sx), h:Math.abs(cy-sy) };
box.style.cssText = `left:${cur.x*100}%;top:${cur.y*100}%;width:${cur.w*100}%;height:${cur.h*100}%`;
};
- const up = () => {
+ const up = async (ev) => {
window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up);
box.remove();
if (cur && cur.w > 0.02 && cur.h > 0.02) {
- suppressClick = true; setTimeout(()=>{ suppressClick=false; }, 80);
- addAnnotation(d, { ...cur, label:'bad' }, el);
+ suppressClick = true; setTimeout(()=>{ suppressClick=false; }, 150);
+ const label = await pickLabel(ev.clientX, ev.clientY); // choose the defect type
+ if (label) addAnnotation(d, { ...cur, label }, el);
}
};
window.addEventListener('mousemove', mv); window.addEventListener('mouseup', up);
});
+ el.querySelector('.auto-id-btn').addEventListener('click', e => { e.stopPropagation(); autoId(d, el, e.currentTarget); });
renderBoxes(d, el);
el.addEventListener('mouseenter', () => hovered = d.id);
el.addEventListener('mouseleave', () => { if(hovered===d.id) hovered=null; });
return el;
}
-// Render the stored bad-area boxes on a card's thumb (idempotent).
+// Render bad-area boxes on a card's thumb (idempotent). Two kinds:
+// • confirmed (d.annotations) — red, saved, with × to remove
+// • candidates (d.candidates) — amber dashed, auto-detected, with ✓ confirm / × reject
+function makeBox(b, isCand, onX, onOk){
+ const div = document.createElement('div');
+ div.className = 'bad-box' + (isCand ? ' cand' : '');
+ div.style.cssText = `left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%`;
+ div.innerHTML = (isCand ? `<span class="ok" title="confirm">✓</span>` : '')
+ + `<span class="x" title="${isCand?'reject':'remove'}">×</span>`
+ + (b.label ? `<span class="lbl">${b.label}</span>` : '');
+ div.querySelector('.x').addEventListener('click', e => { e.stopPropagation(); onX(); });
+ if (isCand) div.querySelector('.ok').addEventListener('click', e => { e.stopPropagation(); onOk(); });
+ return div;
+}
function renderBoxes(d, el){
const thumb = el.querySelector('.thumb');
thumb.querySelectorAll('.bad-box').forEach(n => n.remove());
- (d.annotations || []).forEach((b, i) => {
- const div = document.createElement('div'); div.className = 'bad-box';
- div.style.cssText = `left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%`;
- div.innerHTML = `<span class="x" title="remove">×</span>${b.label?`<span class="lbl">${b.label}</span>`:''}`;
- div.querySelector('.x').addEventListener('click', ev => { ev.stopPropagation(); removeAnnotation(d, i, el); });
- thumb.appendChild(div);
+ (d.annotations || []).forEach((b, i) =>
+ thumb.appendChild(makeBox(b, false, () => removeAnnotation(d, i, el))));
+ (d.candidates || []).forEach((b, i) =>
+ thumb.appendChild(makeBox(b, true,
+ () => { d.candidates.splice(i, 1); renderBoxes(d, el); }, // × reject (discard)
+ () => { d.candidates.splice(i, 1); addAnnotation(d, b, el); }))); // ✓ confirm → save as annotation
+}
+// 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`))
+ .then(r => r.json())
+ .then(j => {
+ if (!j.ok) { alert('Auto-ID failed: ' + (j.error||'unknown')); return; }
+ d.candidates = j.candidates || [];
+ renderBoxes(d, el);
+ if (!d.candidates.length && btn) btn.textContent = '✓ clean';
+ })
+ .catch(err => alert('Auto-ID error: ' + err.message))
+ .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'];
+let _picker = null;
+function closePicker(){ if(_picker){ document.removeEventListener('mousedown', _picker._out, true); _picker.remove(); _picker=null; } }
+function pickLabel(clientX, clientY){
+ return new Promise(resolve => {
+ closePicker();
+ const p = document.createElement('div'); p.className='label-picker'; _picker = p;
+ p.innerHTML = `<div class="lp-h">Bad element:</div>` +
+ BOX_LABELS.map(l=>`<button data-l="${l}">${l}</button>`).join('') +
+ `<button class="lp-cancel" data-l="">cancel</button>`;
+ document.body.appendChild(p);
+ p.style.left = Math.min(clientX, window.innerWidth - 190) + 'px';
+ p.style.top = Math.min(clientY, window.innerHeight - 230) + 'px';
+ const done = l => { closePicker(); resolve(l || null); };
+ p.addEventListener('mousedown', e => e.stopPropagation()); // don't start a marquee
+ p.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); done(b.dataset.l); }));
+ p._out = ev => { if(_picker && !_picker.contains(ev.target)) done(null); };
+ setTimeout(()=> document.addEventListener('mousedown', p._out, true), 0);
});
}
function addAnnotation(d, box, el){ d.annotations = d.annotations || []; d.annotations.push(box); renderBoxes(d, el); saveAnnotations(d); }
diff --git a/server.js b/server.js
index 86a69a9..bc777e1 100644
--- a/server.js
+++ b/server.js
@@ -1354,6 +1354,43 @@ app.post('/api/admin/cactus/:id/annotations', express.json({ limit: '32kb' }), (
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
+// GET /api/admin/cactus/:id/auto-id — Gemini vision SUGGESTS candidate bad-area
+// boxes (Seams/Poor edges/Cartoonish/Blurry/Ghost/Bleed). READ-ONLY: returns
+// candidates for the curator to confirm/delete/add — nothing is saved here.
+// (Claude vision would be more accurate but the Anthropic acct is out of credits;
+// Gemini's inconsistency is absorbed by the human confirm/delete step.)
+app.get('/api/admin/cactus/:id/auto-id', async (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ 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();
+ 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.";
+ const body = { contents: [{ parts: [
+ { inline_data: { mime_type: 'image/png', data: b64 } }, { text: prompt },
+ ] }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } };
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${KEY}`,
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (!r.ok) return res.status(502).json({ ok: false, error: `vision HTTP ${r.status}` });
+ const j = await r.json();
+ let boxes = [];
+ try { boxes = JSON.parse(j.candidates?.[0]?.content?.parts?.[0]?.text || '[]'); } catch {}
+ const c01 = n => Math.max(0, Math.min(1, Number(n) || 0));
+ const candidates = (Array.isArray(boxes) ? boxes : []).slice(0, 8)
+ .map(b => ({ x: c01(b.x), y: c01(b.y), w: c01(b.w), h: c01(b.h), label: String(b.label || 'bad').slice(0, 40) }))
+ .filter(b => b.w > 0.01 && b.h > 0.01);
+ res.json({ ok: true, id, candidates });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
// ── /admin/elements — curated library of "solid opaque middle" designs.
// Browse-first surface for composing new designs from quality-vetted
// existing tiles. Per the elements skill (~/.claude/skills/elements).
← 65bb871 cactus-curator: surface bad_examples.defect_tags as red reas
·
back to Wallco Ai
·
curator: generalize to any collection via ?category= (loads 1dbe891 →