[object Object]

← 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

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 →