← back to Wallco Ai
ghost-review: auto-suggest dotted regions + tolerance slider + auto-labeler
7adcd3c8222275a12d9badfbcbba1e9bb040ce50 · 2026-05-23 23:00:54 -0700 · Steve Abrams
VIEWER — live in-browser CV pass on every image:
- Downsample to 320px → 16x16 grid → per-cell luminance + gradient mag
- Flag cells with low gradient + anomalous luminance = ghost-suspect
- Flood-fill adjacent flagged cells → bounding boxes (max 20)
- Render as dotted blue outlines on stage
- Click any → accepts it as a solid rect with current draw mode (G/B/O)
- Tolerance slider (strict to loose) re-runs the heuristic instantly
- Toggle off entirely via the checkbox
Lets Steve work the queue 10x faster — instead of dragging every rect, he
clicks the dotted previews the CV pass already drew, then adjusts.
AUTO-LABELER (scripts/auto-label-ghost.js):
- Second-opinion vision pass via Mac1 Ollama qwen2.5vl:7b
- Category-aware prompting: knows Muybridge motion-studies, damask
tone-on-tone, scenic atmospheric perspective are design intent
- Writes labels with source=auto, model=qwen2.5vl:7b@mac1 — manual
labels (added later) overwrite via last-wins per id
- Free, unmetered, fits the no-Anthropic-API standing rule
- Concurrency 4 on Mac1 -> ~9-10h for 6,305 items
Files touched
M data/ghost-labels.jsonlM public/admin/ghost-review.htmlA scripts/auto-label-ghost.js
Diff
commit 7adcd3c8222275a12d9badfbcbba1e9bb040ce50
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 23:00:54 2026 -0700
ghost-review: auto-suggest dotted regions + tolerance slider + auto-labeler
VIEWER — live in-browser CV pass on every image:
- Downsample to 320px → 16x16 grid → per-cell luminance + gradient mag
- Flag cells with low gradient + anomalous luminance = ghost-suspect
- Flood-fill adjacent flagged cells → bounding boxes (max 20)
- Render as dotted blue outlines on stage
- Click any → accepts it as a solid rect with current draw mode (G/B/O)
- Tolerance slider (strict to loose) re-runs the heuristic instantly
- Toggle off entirely via the checkbox
Lets Steve work the queue 10x faster — instead of dragging every rect, he
clicks the dotted previews the CV pass already drew, then adjusts.
AUTO-LABELER (scripts/auto-label-ghost.js):
- Second-opinion vision pass via Mac1 Ollama qwen2.5vl:7b
- Category-aware prompting: knows Muybridge motion-studies, damask
tone-on-tone, scenic atmospheric perspective are design intent
- Writes labels with source=auto, model=qwen2.5vl:7b@mac1 — manual
labels (added later) overwrite via last-wins per id
- Free, unmetered, fits the no-Anthropic-API standing rule
- Concurrency 4 on Mac1 -> ~9-10h for 6,305 items
---
data/ghost-labels.jsonl | 36 +++++++
public/admin/ghost-review.html | 210 +++++++++++++++++++++++++++++++++++++-
scripts/auto-label-ghost.js | 222 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 466 insertions(+), 2 deletions(-)
diff --git a/data/ghost-labels.jsonl b/data/ghost-labels.jsonl
index e69de29..19bf1c0 100644
--- a/data/ghost-labels.jsonl
+++ b/data/ghost-labels.jsonl
@@ -0,0 +1,36 @@
+{"id":39264,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: The distant mountains appear as a fainter, duplicated layer behind the foreground.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.8,"detector_confidence":0.95,"detector_reason":"The image appears to have a translucent overlay of the same scene, creating a ghost layer effect.","ts":"2026-05-24T05:52:08.522Z"}
+{"id":39268,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: Faint, duplicated cactus motifs appear behind the foreground, suggesting a layering issue.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"The trees in the foreground are duplicated in a faded, translucent layer over the rest of the image.","ts":"2026-05-24T05:52:13.686Z"}
+{"id":39272,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: Faint, duplicated cacti and pine trees appear behind the sharper foreground elements.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"The image has a translucent ghost overlay of the whole scene, creating a duplicated effect.","ts":"2026-05-24T05:52:18.834Z"}
+{"id":39264,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: The distant mountains appear as a fainter, duplicated layer behind the foreground.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.8,"detector_confidence":0.95,"detector_reason":"The image appears to have a translucent overlay of the same scene, creating a ghost layer effect.","ts":"2026-05-24T05:52:44.763Z"}
+{"id":39268,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: Faint, duplicated cactus motifs appear behind the foreground, not attributable to atmospheric perspective.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"The trees in the foreground are duplicated in a faded, translucent layer over the rest of the image.","ts":"2026-05-24T05:52:49.744Z"}
+{"id":39272,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: Faint, duplicated cactus and pine motifs appear behind the sharper foreground elements.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"The image has a translucent ghost overlay of the whole scene, creating a duplicated effect.","ts":"2026-05-24T05:52:54.664Z"}
+{"id":39266,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: The distant cacti appear as a fainter, duplicated layer behind the foreground elements.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.8,"detector_confidence":0.99,"detector_reason":"The entire scene is duplicated with a translucent overlay, creating a ghost layer effect.","ts":"2026-05-24T05:52:59.638Z"}
+{"id":39155,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: The lips appear twice, one sharp and one faded behind, not attributable to design intent.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"Some lip motifs have a faded, semi-transparent copy behind the main, solid-colored motif.","ts":"2026-05-24T05:53:04.883Z"}
+{"id":39161,"verdict":"tp_ghost","crops":[],"crop":null,"notes":"auto: The floral motif appears twice, one sharp and one faded behind, suggesting an unintended duplicate layer.","source":"auto","model":"gemma3:12b@mac2","qwen_confidence":0.7,"detector_confidence":0.95,"detector_reason":"Some of the brown shapes have a faded, semi-transparent copy behind them, creating a ghost layer effect.","ts":"2026-05-24T05:53:10.133Z"}
+{"id":39264,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image appears to have a translucent overlay of the same scene, creating a ghost layer effect.","ts":"2026-05-24T05:54:05.546Z"}
+{"id":39268,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The trees in the foreground are duplicated in a faded, translucent layer over the rest of the image.","ts":"2026-05-24T05:54:18.710Z"}
+{"id":39272,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same tile/scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image has a translucent ghost overlay of the whole scene, creating a duplicated effect.","ts":"2026-05-24T05:54:31.891Z"}
+{"id":39264,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image appears to have a translucent overlay of the same scene, creating a ghost layer effect.","ts":"2026-05-24T05:55:02.318Z"}
+{"id":39272,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same tile/scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image has a translucent ghost overlay of the whole scene, creating a duplicated effect.","ts":"2026-05-24T05:55:15.373Z"}
+{"id":39268,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The trees in the foreground are duplicated in a faded, translucent layer over the rest of the image.","ts":"2026-05-24T05:55:28.246Z"}
+{"id":39266,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same tile/scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.99,"detector_reason":"The entire scene is duplicated with a translucent overlay, creating a ghost layer effect.","ts":"2026-05-24T05:55:41.482Z"}
+{"id":39155,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Some lip motifs have a faded, semi-transparent copy behind the main, solid-colored motif.","ts":"2026-05-24T05:55:57.688Z"}
+{"id":39161,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicate motif at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Some of the brown shapes have a faded, semi-transparent copy behind them, creating a ghost layer effect.","ts":"2026-05-24T05:56:13.778Z"}
+{"id":39159,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent copies of the red shapes appear behind the solid red motifs, creating a ghost layer effect.","ts":"2026-05-24T05:56:29.999Z"}
+{"id":39136,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image appears to have a repeating pattern with a translucent overlay, indicating a ghost layer.","ts":"2026-05-24T05:56:42.864Z"}
+{"id":37919,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif appearing twice at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent silhouettes of the figures appear behind the solid figures, creating a ghost layer effect.","ts":"2026-05-24T05:56:59.018Z"}
+{"id":37916,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif appearing twice at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent copies of the figures appear behind the solid ones, creating a ghost layer effect.","ts":"2026-05-24T05:57:15.171Z"}
+{"id":37915,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicate motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Faint, semi-transparent silhouettes of the figures appear behind the solid figures, creating a ghost layer effect.","ts":"2026-05-24T05:57:31.370Z"}
+{"id":37912,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No translucent ghost overlay; intentional design repetition.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The same figures appear in both sharp focus and as faded, semi-transparent copies behind them.","ts":"2026-05-24T05:57:47.429Z"}
+{"id":39264,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image appears to have a translucent overlay of the same scene, creating a ghost layer effect.","ts":"2026-05-24T05:58:00.613Z"}
+{"id":39155,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Some lip motifs have a faded, semi-transparent copy behind the main, solid-colored motif.","ts":"2026-05-24T05:58:16.825Z"}
+{"id":39161,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicate motif at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Some of the brown shapes have a faded, semi-transparent copy behind them, creating a ghost layer effect.","ts":"2026-05-24T05:58:32.922Z"}
+{"id":39268,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The trees in the foreground are duplicated in a faded, translucent layer over the rest of the image.","ts":"2026-05-24T05:58:45.799Z"}
+{"id":39266,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif with different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.99,"detector_reason":"The entire scene is duplicated with a translucent overlay, creating a ghost layer effect.","ts":"2026-05-24T05:58:58.685Z"}
+{"id":39136,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same tile/scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image appears to have a repeating pattern with a translucent overlay, indicating a ghost layer.","ts":"2026-05-24T05:59:11.912Z"}
+{"id":39272,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No duplicated motif in the same tile/scene at different opacities.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The image has a translucent ghost overlay of the whole scene, creating a duplicated effect.","ts":"2026-05-24T05:59:24.963Z"}
+{"id":39159,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent copies of the red shapes appear behind the solid red motifs, creating a ghost layer effect.","ts":"2026-05-24T05:59:41.185Z"}
+{"id":37919,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No identical motifs at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent silhouettes of the figures appear behind the solid figures, creating a ghost layer effect.","ts":"2026-05-24T05:59:57.397Z"}
+{"id":37913,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No translucent duplicates of the same motif are present.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"The same animal motifs appear in both solid and semi-transparent versions, creating a ghost layer effect.","ts":"2026-05-24T06:00:13.373Z"}
+{"id":37907,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No same motif at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.9,"detector_reason":"Faint, semi-transparent copies of the roses appear behind the main motifs, creating a ghost layer effect.","ts":"2026-05-24T06:00:29.620Z"}
+{"id":37909,"verdict":"fp","crops":[],"crop":null,"notes":"auto: No identical motifs at different opacities in the same tile.","source":"auto","model":"qwen2.5vl:7b@mac1","qwen_confidence":0.9,"detector_confidence":0.95,"detector_reason":"Faded cactus motifs appear behind the sharp, solid ones, creating a ghost layer effect.","ts":"2026-05-24T06:00:45.810Z"}
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 3aa773c..d97e076 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -177,6 +177,23 @@
position:absolute; pointer-events:none;
border:1px dashed rgba(255,174,58,.6); box-sizing:border-box;
}
+ /* Auto-suggested dotted regions */
+ .stage .sgst {
+ position:absolute; box-sizing:border-box; cursor:pointer;
+ border:2px dashed rgba(120,180,255,.85);
+ background:rgba(120,180,255,.10);
+ transition:background .12s, border-color .12s;
+ }
+ .stage .sgst:hover {
+ background:rgba(120,180,255,.30);
+ border-color:#7cb8ff;
+ }
+ .stage .sgst .sgst-label {
+ position:absolute; top:-18px; left:0;
+ background:#3b78d8; color:#fff; font:600 9px var(--sans);
+ letter-spacing:.06em; padding:1px 4px; border-radius:2px;
+ pointer-events:none; white-space:nowrap;
+ }
/* Right panel */
.panel {
@@ -434,6 +451,19 @@
<button class="m-other" data-mode="other">Other</button>
</div>
<div class="rect-count" id="rect-count">no regions marked yet</div>
+
+ <h3>Auto-detect suggestions
+ <span style="margin-left:8px"><input type="checkbox" id="auto-on" checked> <label for="auto-on" style="font:11px var(--sans); color:var(--faint); text-transform:none; letter-spacing:0;">show dotted previews</label></span>
+ </h3>
+ <div style="display:flex; gap:8px; align-items:center; font:11px ui-monospace,Menlo,monospace;">
+ <span style="color:var(--faint)">strict</span>
+ <input type="range" id="tolerance" min="0" max="100" step="5" value="40" style="flex:1">
+ <span style="color:var(--faint)">loose</span>
+ <span id="tol-val" style="min-width:30px; text-align:right">40</span>
+ </div>
+ <div style="font:11px var(--sans); color:var(--faint); margin-top:-4px">
+ <span id="sgst-count">—</span> suggested regions · click a dotted box to accept it as <span id="sgst-mode-label" style="font-weight:600">ghost</span>
+ </div>
<div class="undo-row">
<button id="undo-btn">↶ undo last rect (U)</button>
<span style="margin-left:auto">·</span>
@@ -716,12 +746,14 @@
drawRects();
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
+ runAutoSuggest();
};
mainImg.src = item.image_url;
if (mainImg.complete) {
drawRects();
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
+ runAutoSuggest();
}
$('notes').value = (lbl && lbl.notes) || '';
$('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
@@ -750,6 +782,8 @@
b.classList.toggle('active', b.dataset.mode === DRAW_MODE);
});
$('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
+ const lbl = $('sgst-mode-label');
+ if (lbl) lbl.textContent = DRAW_MODE;
}
// ── Drawing ────────────────────────────────────────────────────────────
@@ -920,11 +954,183 @@
btn.innerHTML = '<span class="icon">⊞</span> Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>';
$('top-hint').innerHTML = 'Drag to mark defect regions — then press <b>G</b> ghost or <b>B</b> seam-break';
}
- // Re-render rects in the new view
- setTimeout(() => { drawRects(); }, 0);
+ // Re-render rects + suggestions in the new view
+ setTimeout(() => { drawRects(); drawSuggestions(); }, 0);
}
$('seam-test-btn').onclick = toggleSeamView;
+ // ── Auto-suggest dotted preview regions ────────────────────────────────
+ // Lightweight in-browser CV: downsample → grid → per-cell luminance +
+ // gradient magnitude. Cells with moderate luminance (foreground) but
+ // unusually LOW gradient (faded / low-contrast) are flagged as
+ // ghost-suspect. Adjacent flagged cells get merged into bounding boxes.
+ // Tolerance slider controls the gradient threshold — strict=few boxes,
+ // loose=many boxes. NOT a final-defect detector — Steve still confirms.
+ let SUGGESTIONS = []; // [{x,y,w,h,score}] normalized 0..1
+ let TOLERANCE = parseInt(localStorage.getItem('gr-tolerance') || '40', 10);
+ let AUTO_ON = (localStorage.getItem('gr-auto-on') || '1') === '1';
+ $('tolerance').value = TOLERANCE;
+ $('tol-val').textContent = TOLERANCE;
+ $('auto-on').checked = AUTO_ON;
+ $('tolerance').oninput = (e) => {
+ TOLERANCE = parseInt(e.target.value, 10);
+ $('tol-val').textContent = TOLERANCE;
+ localStorage.setItem('gr-tolerance', TOLERANCE);
+ runAutoSuggest();
+ };
+ $('auto-on').onchange = (e) => {
+ AUTO_ON = e.target.checked;
+ localStorage.setItem('gr-auto-on', AUTO_ON ? '1' : '0');
+ runAutoSuggest();
+ };
+
+ function runAutoSuggest() {
+ // Clear old suggestions
+ imgWrap.querySelectorAll('.sgst').forEach(el => el.remove());
+ SUGGESTIONS = [];
+ if (!AUTO_ON || CURRENT === null || !mainImg.naturalWidth) {
+ $('sgst-count').textContent = AUTO_ON ? '0' : 'off';
+ return;
+ }
+ // Update mode-label
+ $('sgst-mode-label').textContent = DRAW_MODE;
+ try {
+ SUGGESTIONS = computeSuggestions(mainImg, TOLERANCE);
+ } catch (e) {
+ console.warn('suggest CV failed:', e.message);
+ $('sgst-count').textContent = 'err';
+ return;
+ }
+ $('sgst-count').textContent = SUGGESTIONS.length;
+ drawSuggestions();
+ }
+
+ function computeSuggestions(imgEl, tolerance) {
+ // Downsample to a max edge of 320 for speed
+ const SZ = 320;
+ const nw = imgEl.naturalWidth, nh = imgEl.naturalHeight;
+ const sw = nw >= nh ? SZ : Math.round(SZ * nw / nh);
+ const sh = nh >= nw ? SZ : Math.round(SZ * nh / nw);
+ const cv = document.createElement('canvas');
+ cv.width = sw; cv.height = sh;
+ const ctx = cv.getContext('2d');
+ ctx.drawImage(imgEl, 0, 0, sw, sh);
+ const px = ctx.getImageData(0, 0, sw, sh).data;
+
+ // 16x16 grid; per-cell mean luminance + gradient magnitude
+ const GRID = 16;
+ const cw = Math.floor(sw / GRID), ch = Math.floor(sh / GRID);
+ const cells = [];
+ for (let cy = 0; cy < GRID; cy++) {
+ const row = [];
+ for (let cx = 0; cx < GRID; cx++) {
+ let sumL = 0, sumG = 0, n = 0;
+ const xs = cx * cw, ys = cy * ch;
+ for (let y = ys; y < ys + ch && y < sh - 1; y++) {
+ for (let x = xs; x < xs + cw && x < sw - 1; x++) {
+ const i = (y * sw + x) * 4;
+ const L = 0.299*px[i] + 0.587*px[i+1] + 0.114*px[i+2];
+ const Lr = 0.299*px[i+4] + 0.587*px[i+5] + 0.114*px[i+6];
+ const Ld = 0.299*px[i+sw*4] + 0.587*px[i+sw*4+1] + 0.114*px[i+sw*4+2];
+ sumL += L;
+ sumG += Math.abs(L - Lr) + Math.abs(L - Ld);
+ n++;
+ }
+ }
+ row.push({ cx, cy, meanL: sumL / n, meanG: sumG / n, marked: false });
+ }
+ cells.push(row);
+ }
+ const flat = cells.flat();
+ // Background reference = the most-common (mode-ish) luminance — assume
+ // a margin around the mean is "background". Anything outside that is
+ // motif content. Ghost = motif content with LOW gradient.
+ const meanL = flat.reduce((a,b) => a + b.meanL, 0) / flat.length;
+ const stdL = Math.sqrt(flat.reduce((a,b) => a + (b.meanL - meanL) ** 2, 0) / flat.length);
+ const meanG = flat.reduce((a,b) => a + b.meanG, 0) / flat.length;
+ const stdG = Math.sqrt(flat.reduce((a,b) => a + (b.meanG - meanG) ** 2, 0) / flat.length);
+ // Tolerance 0..100 → 0..1
+ const t = tolerance / 100;
+ // Strict (t=0) → only flag cells with gradient < mean - 1.5*std AND luminance unusual
+ // Loose (t=1) → flag cells with gradient < mean + 0.5*std
+ const gradThresh = meanG + stdG * (-1.5 + 2.0 * t);
+ const lumOff = stdL * (0.4 - 0.2 * t); // looser = smaller off requirement
+ for (const c of flat) {
+ const lumAnomaly = Math.abs(c.meanL - meanL) > lumOff;
+ if (c.meanG < gradThresh && lumAnomaly) c.marked = true;
+ }
+
+ // Group adjacent marked cells into bounding boxes (4-connectivity flood)
+ const visited = new Set();
+ const boxes = [];
+ function key(x, y) { return x + ',' + y; }
+ for (let cy = 0; cy < GRID; cy++) {
+ for (let cx = 0; cx < GRID; cx++) {
+ if (!cells[cy][cx].marked || visited.has(key(cx,cy))) continue;
+ const stack = [[cx,cy]];
+ let minX=cx, maxX=cx, minY=cy, maxY=cy, score = 0, count = 0;
+ while (stack.length) {
+ const [x,y] = stack.pop();
+ if (visited.has(key(x,y))) continue;
+ visited.add(key(x,y));
+ if (x<0||y<0||x>=GRID||y>=GRID) continue;
+ const c = cells[y][x];
+ if (!c.marked) continue;
+ if (x<minX) minX=x; if (x>maxX) maxX=x;
+ if (y<minY) minY=y; if (y>maxY) maxY=y;
+ score += (gradThresh - c.meanG);
+ count++;
+ stack.push([x+1,y]); stack.push([x-1,y]);
+ stack.push([x,y+1]); stack.push([x,y-1]);
+ }
+ if (count < 2) continue; // skip lone cells (noise)
+ boxes.push({
+ x: minX / GRID,
+ y: minY / GRID,
+ w: (maxX - minX + 1) / GRID,
+ h: (maxY - minY + 1) / GRID,
+ score: score / count,
+ cells: count,
+ });
+ }
+ }
+ // Sort by score descending, cap to 20 suggestions
+ boxes.sort((a,b) => b.score - a.score);
+ return boxes.slice(0, 20);
+ }
+
+ function drawSuggestions() {
+ imgWrap.querySelectorAll('.sgst').forEach(el => el.remove());
+ if (!AUTO_ON || !SUGGESTIONS.length) return;
+ const activeEl = activeImageEl();
+ const r = activeEl.getBoundingClientRect();
+ const wr = imgWrap.getBoundingClientRect();
+ const offX = r.left - wr.left, offY = r.top - wr.top;
+ SUGGESTIONS.forEach((s, i) => {
+ const el = document.createElement('div');
+ el.className = 'sgst';
+ el.style.left = (offX + s.x * r.width) + 'px';
+ el.style.top = (offY + s.y * r.height) + 'px';
+ el.style.width = (s.w * r.width) + 'px';
+ el.style.height = (s.h * r.height) + 'px';
+ el.title = `accept as ${DRAW_MODE} (click) — score ${s.score.toFixed(1)} · ${s.cells} cells`;
+ el.innerHTML = `<div class="sgst-label">SUGGEST #${i+1} · ${(s.score).toFixed(0)}</div>`;
+ el.onclick = (e) => {
+ e.stopPropagation();
+ RECTS.push({ x: s.x, y: s.y, w: s.w, h: s.h, tag: DRAW_MODE, view: SEAM_VIEW ? 'seam-quadrant-swap' : 'original', source: 'suggested' });
+ // Remove this suggestion (already accepted)
+ SUGGESTIONS = SUGGESTIONS.filter((_, j) => j !== i);
+ drawRects();
+ drawSuggestions();
+ updateRectCount();
+ $('sgst-count').textContent = SUGGESTIONS.length;
+ };
+ imgWrap.appendChild(el);
+ });
+ }
+ // Re-render suggestions on window resize so they stay anchored
+ window.addEventListener('resize', () => { drawSuggestions(); });
+
// ── Save verdict ───────────────────────────────────────────────────────
function toast(msg) {
const t = $('toast');
diff --git a/scripts/auto-label-ghost.js b/scripts/auto-label-ghost.js
new file mode 100644
index 0000000..1380c14
--- /dev/null
+++ b/scripts/auto-label-ghost.js
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+// auto-label-ghost — second-opinion vision recheck of ghost-detector
+// flags using a DIFFERENT model (Mac1 Ollama qwen2.5vl) than the
+// Gemini detector that produced the flags. The hypothesis: the Gemini
+// detector over-flags translucent/layered design intent (Muybridge
+// plates, atmospheric perspective scenics, multi-opacity stylization)
+// as "ghost layer" defects. A second-opinion pass with category-aware
+// prompting should agree on the real defects and disagree on the
+// false-positives, giving us auto-labels we can sanity-check in the
+// viewer instead of clicking through 6,305 by hand.
+//
+// Auto-labels are written to data/ghost-labels.jsonl with:
+// source: 'auto'
+// model: 'qwen2.5vl:7b@mac1'
+// so they don't collide with Steve's manual labels (read-side uses
+// last-wins per id — manual labels added later will overwrite).
+//
+// Usage:
+// node scripts/auto-label-ghost.js # all flagged, not yet labeled
+// node scripts/auto-label-ghost.js --limit 50 # smoke test
+// node scripts/auto-label-ghost.js --concurrency 4 # default 4 (Mac1 capacity)
+// node scripts/auto-label-ghost.js --category cactus
+
+const fs = require('fs');
+const path = require('path');
+
+const OLLAMA_URL = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
+const MODEL = process.env.AUTO_LABEL_MODEL || 'qwen2.5vl:7b';
+
+const ARGS = process.argv.slice(2);
+const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i + 1] : d; };
+const LIMIT = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '4'), 10));
+const CATEGORY = arg('category', null);
+const FORCE = ARGS.includes('--force'); // re-label even if already labeled
+
+const ROOT = path.join(__dirname, '..');
+const FLAGGED_F = path.join(ROOT, 'data', 'ghost-scan-flagged.jsonl');
+const LABELS_F = path.join(ROOT, 'data', 'ghost-labels.jsonl');
+const IMG_DIR = path.join(ROOT, 'data', 'generated');
+
+// ─ Category-aware framing: tell qwen what the intended design is so it
+// doesn't over-flag intentional translucency as "ghost layer." Falls back
+// to a generic frame if category unknown.
+const CATEGORY_CONTEXT = {
+ 'muybridge-plate': "Muybridge designs INTENTIONALLY show the same animal in sequential frozen positions (motion study). Multiple instances of the same figure is the design, not a defect. Only flag if there's a TRANSLUCENT GHOST OVERLAY separate from the intentional motion-study repetition.",
+ 'cactus-pine-scenic': "Scenic landscapes have legitimate atmospheric perspective — distant elements naturally appear fainter. That is NOT a ghost layer. Only flag if you see a duplicated stacked translucent copy of the entire scene.",
+ 'designer-scenic': "Scenic murals have atmospheric perspective. Faded distant elements = depth, not defect. Flag only true ghost overlays (whole-scene stacked translucent copy).",
+ 'damask': "Damasks often use tone-on-tone (darker motif over lighter background of the same hue). That is the design, not a ghost. Only flag if there are DUPLICATE motifs at different opacities — same shape appearing twice.",
+ 'drunk-animals': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
+ 'drunk-monkeys-v2': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
+ 'designer-zoo-calm': "Stylized animals may have soft outlines. Only flag actual translucent duplicates of the same motif.",
+ 'stoned-animals': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
+ 'face-skull-damask': "Damask tone-on-tone is normal. Only flag duplicate motifs at different opacities.",
+ 'throttled-plaster-roses': "Plaster/relief effects use shadow + lighting layers by design — that is the medium, not a defect. Flag only when the SAME bloom appears twice at different opacities.",
+ 'chinoiserie': "Chinoiserie scenics use atmospheric perspective and layered fabric/inkwash effects by design. Only flag a duplicate stacked translucent copy.",
+ 'stripe': "Stripes are flat geometric repeats — any duplicate offset stripe at lower opacity IS a ghost layer (very rare for stripes to need atmospheric depth).",
+};
+const DEFAULT_CONTEXT = "A 'ghost layer' = the SAME motif appearing TWICE at different opacities — one sharp, one faded behind. Atmospheric perspective in scenics (distant elements naturally fainter) is NOT a ghost layer. Intentional design layering (Muybridge motion studies, damask tone-on-tone, plaster relief shading) is NOT a ghost layer. Only flag a true unintended duplicate-at-different-opacity defect.";
+
+function buildPrompt(category) {
+ const ctx = CATEGORY_CONTEXT[category] || DEFAULT_CONTEXT;
+ return `You are giving a SECOND OPINION on whether this wallpaper image has a "ghost layer" defect. A previous detector flagged it; we suspect the previous detector confuses intentional design layering with defects.
+
+CATEGORY-SPECIFIC CONTEXT for this image (category="${category || 'unknown'}"):
+${ctx}
+
+A "ghost layer" defect = the SAME motif appearing TWICE in the SAME tile/scene at different opacities — one sharp, one translucent behind it, NOT explained by the design intent above.
+
+Reply with ONLY this JSON (no markdown, no prose):
+{"hasGhostLayer": true|false, "confidence": <0..1>, "reason": "<short ≤20 word explanation>", "looks_like_seam_break": true|false}
+
+"looks_like_seam_break" = TRUE if you see hard edges/discontinuities suggesting the tile pattern is broken at the image edges (not the same as a ghost layer — a separate defect).`;
+}
+
+// ─ Already-labeled set (so we skip them unless --force)
+function loadLabeled() {
+ if (!fs.existsSync(LABELS_F)) return new Set();
+ const ids = new Set();
+ for (const line of fs.readFileSync(LABELS_F, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try { ids.add(JSON.parse(line).id); } catch {}
+ }
+ return ids;
+}
+
+// ─ Load flagged set
+function loadFlagged() {
+ const rows = [];
+ if (!fs.existsSync(FLAGGED_F)) return rows;
+ for (const line of fs.readFileSync(FLAGGED_F, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try { rows.push(JSON.parse(line)); } catch {}
+ }
+ return rows;
+}
+
+// ─ Image bytes: resolve PG local_path OR fall back to /by-id route
+async function loadImageB64(item) {
+ // The flagged-set rows don't carry local_path. Look up via designs.json
+ // index OR fall back to fetching from the local server's by-id route.
+ const url = `http://127.0.0.1:9877/designs/img/by-id/${item.id}`;
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`fetch ${r.status}`);
+ const buf = Buffer.from(await r.arrayBuffer());
+ if (buf.length < 1024) throw new Error(`tiny body ${buf.length}b`);
+ return buf.toString('base64');
+}
+
+// ─ Single qwen2.5vl call
+async function classifyOne(item) {
+ const b64 = await loadImageB64(item);
+ const body = {
+ model: MODEL,
+ prompt: buildPrompt(item.category),
+ images: [b64],
+ stream: false,
+ format: 'json',
+ options: { temperature: 0.1, num_ctx: 2048 },
+ };
+ const r = await fetch(`${OLLAMA_URL}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 200)}`);
+ const j = await r.json();
+ // qwen with format:json returns the JSON in `response` as a string
+ let parsed;
+ try { parsed = JSON.parse(j.response); }
+ catch { throw new Error(`bad json from model: ${j.response.slice(0, 200)}`); }
+ return parsed;
+}
+
+// ─ Combine classifier output into a verdict + crops
+function toLabel(item, qwen) {
+ // Decide:
+ // qwen agrees ghost → tp_ghost (no crops — auto labels don't have boxes)
+ // qwen says seam-break → tp_seam (no crops)
+ // qwen says clean → fp
+ if (qwen.looks_like_seam_break && !qwen.hasGhostLayer) {
+ return { verdict: 'tp_seam', conf: qwen.confidence || 0.5, reason: qwen.reason };
+ }
+ if (qwen.hasGhostLayer) {
+ return { verdict: 'tp_ghost', conf: qwen.confidence || 0.5, reason: qwen.reason };
+ }
+ return { verdict: 'fp', conf: qwen.confidence || 0.5, reason: qwen.reason };
+}
+
+// ─ Append a label row
+function writeLabel(item, lbl) {
+ const row = {
+ id: item.id,
+ verdict: lbl.verdict,
+ crops: [],
+ crop: null,
+ notes: `auto: ${lbl.reason || ''}`.slice(0, 480),
+ source: 'auto',
+ model: `${MODEL}@${OLLAMA_URL.includes('192.168.1.133') ? 'mac1' : 'mac2'}`,
+ qwen_confidence: lbl.conf,
+ detector_confidence: item.confidence,
+ detector_reason: item.reason,
+ ts: new Date().toISOString(),
+ };
+ fs.appendFileSync(LABELS_F, JSON.stringify(row) + '\n');
+}
+
+// ─ Concurrency pool
+async function pool(items, n, fn) {
+ let i = 0;
+ const next = () => (i < items.length ? items[i++] : null);
+ async function worker() {
+ while (true) {
+ const it = next();
+ if (!it) return;
+ try { await fn(it); }
+ catch (e) { console.error(`[auto-label] id=${it.id} ERR ${e.message.slice(0, 120)}`); }
+ }
+ }
+ await Promise.all(Array.from({ length: n }, worker));
+}
+
+// ─ Main
+(async () => {
+ console.log(`[auto-label] model=${MODEL} ollama=${OLLAMA_URL} concurrency=${CONCURRENCY}`);
+ // Endpoint check
+ const probe = await fetch(`${OLLAMA_URL}/api/tags`).catch(() => null);
+ if (!probe || !probe.ok) {
+ console.error('Ollama endpoint unreachable:', OLLAMA_URL);
+ process.exit(1);
+ }
+ const labeled = FORCE ? new Set() : loadLabeled();
+ let flagged = loadFlagged();
+ if (CATEGORY) flagged = flagged.filter(r => r.category === CATEGORY);
+ const todo = flagged.filter(r => !labeled.has(r.id));
+ const work = LIMIT > 0 ? todo.slice(0, LIMIT) : todo;
+ console.log(`[auto-label] flagged=${flagged.length} already-labeled=${labeled.size} todo=${work.length}`);
+
+ let done = 0, byVerdict = { tp_ghost: 0, tp_seam: 0, fp: 0 }, errs = 0;
+ const t0 = Date.now();
+ await pool(work, CONCURRENCY, async (item) => {
+ let qwen;
+ try { qwen = await classifyOne(item); }
+ catch (e) { errs++; throw e; }
+ const lbl = toLabel(item, qwen);
+ writeLabel(item, lbl);
+ byVerdict[lbl.verdict]++;
+ done++;
+ if (done % 10 === 0 || done === work.length) {
+ const rate = done / ((Date.now() - t0) / 1000);
+ const eta = ((work.length - done) / rate).toFixed(0);
+ const fpPct = ((byVerdict.fp / done) * 100).toFixed(1);
+ console.log(
+ `[auto-label] ${done}/${work.length} · ` +
+ `fp=${byVerdict.fp}(${fpPct}%) ghost=${byVerdict.tp_ghost} seam=${byVerdict.tp_seam} err=${errs} · ` +
+ `${rate.toFixed(2)}/s ETA ${eta}s`
+ );
+ }
+ });
+ console.log(`[auto-label] done · ${done} labels written · ${errs} errors`);
+ console.log(`[auto-label] verdicts: fp=${byVerdict.fp} ghost=${byVerdict.tp_ghost} seam=${byVerdict.tp_seam}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
← 150060a ghost-review: more robust labeling — multi-rect, defect type
·
back to Wallco Ai
·
fix(ghost-detector): kill ~36% false-positive flag rate on f 47f7a9c →