[object Object]

← back to Wallco Ai

ghost-review: more robust labeling — multi-rect, defect types, seam test

150060aba2dbfff20bad5d692545192d3953d444 · 2026-05-23 22:46:01 -0700 · Steve Abrams

UX overhaul of the admin/ghost-review viewer for fast batch labeling:

* Always-on draw mode (no more arming gesture) — just drag on the image
* Multiple rects per image with per-rect × delete + right-click delete
* Three defect types: ghost-layer / seam-break / other
* Per-rect tagging via mode chips (1=ghost 2=seam 3=other)
* Big colored verdict buttons: F=clean G=ghost B=seam O=other S=skip
* Auto-advance to next unlabeled after save
* Notes field auto-shows for "other" defect

NEW: Seam-test toggle (X) — quadrant-swap renders to canvas using the
classic Photoshop Offset filter trick. Original outer edges meet at a
dashed center cross. If the wallpaper tiles cleanly, the cross is
invisible; if there's a seam-break, it screams. Drawing still works on
the seam view; rects are tagged view='seam-quadrant-swap' so downstream
training code can distinguish them from rects on the original frame.

NEW: Live Detector Analysis panel — appears at 5+ labels, recomputes
client-side after every save:
  - Detector precision (TP / TP+FP)
  - False-positive rate with red bar
  - Defect type mix (ghost vs seam vs other)
  - FP-rate by confidence band (0.5-0.7 / 0.7-0.9 / 0.9+)
  - FP-rate by category (top 6) — surfaces categories where the
    detector is systematically wrong, prompting per-category prompt
    refinement
  - Coverage progress vs full flagged set

Server label endpoint now accepts:
  verdict ∈ {fp, tp_ghost, tp_seam, tp_other, skip}  (legacy 'tp' still
  honored on read)
  crops: array of {x,y,w,h,tag,view}
  notes: free-form (≤500 chars)

Files touched

Diff

commit 150060aba2dbfff20bad5d692545192d3953d444
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 22:46:01 2026 -0700

    ghost-review: more robust labeling — multi-rect, defect types, seam test
    
    UX overhaul of the admin/ghost-review viewer for fast batch labeling:
    
    * Always-on draw mode (no more arming gesture) — just drag on the image
    * Multiple rects per image with per-rect × delete + right-click delete
    * Three defect types: ghost-layer / seam-break / other
    * Per-rect tagging via mode chips (1=ghost 2=seam 3=other)
    * Big colored verdict buttons: F=clean G=ghost B=seam O=other S=skip
    * Auto-advance to next unlabeled after save
    * Notes field auto-shows for "other" defect
    
    NEW: Seam-test toggle (X) — quadrant-swap renders to canvas using the
    classic Photoshop Offset filter trick. Original outer edges meet at a
    dashed center cross. If the wallpaper tiles cleanly, the cross is
    invisible; if there's a seam-break, it screams. Drawing still works on
    the seam view; rects are tagged view='seam-quadrant-swap' so downstream
    training code can distinguish them from rects on the original frame.
    
    NEW: Live Detector Analysis panel — appears at 5+ labels, recomputes
    client-side after every save:
      - Detector precision (TP / TP+FP)
      - False-positive rate with red bar
      - Defect type mix (ghost vs seam vs other)
      - FP-rate by confidence band (0.5-0.7 / 0.7-0.9 / 0.9+)
      - FP-rate by category (top 6) — surfaces categories where the
        detector is systematically wrong, prompting per-category prompt
        refinement
      - Coverage progress vs full flagged set
    
    Server label endpoint now accepts:
      verdict ∈ {fp, tp_ghost, tp_seam, tp_other, skip}  (legacy 'tp' still
      honored on read)
      crops: array of {x,y,w,h,tag,view}
      notes: free-form (≤500 chars)
---
 .gitignore                     |   1 +
 public/admin/ghost-review.html | 977 ++++++++++++++++++++++++++++++-----------
 server.js                      |  35 +-
 3 files changed, 760 insertions(+), 253 deletions(-)

diff --git a/.gitignore b/.gitignore
index 69e6334..f8dd984 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ data/generated_pre_seamless_backup/
 data/moodboards.json
 data/reviews.json
 data/sample-requests.jsonl
+data/ghost-scan-*.jsonl
 data/furnish-cache/
 public/marketplace/uploads/colorway-*.png
 public/marketplace/uploads/colorway-*.jpg
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index bd996b8..3aa773c 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -9,37 +9,38 @@
   :root {
     --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
     --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
-    --red:#b3261e; --green:#1f6a2c; --yellow:#a06c00;
+    --green:#1f6a2c; --red:#b3261e; --orange:#c0660b; --grey:#888;
     --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
     --serif:'Playfair Display','Didot',Georgia,serif;
   }
   * { box-sizing:border-box; }
   body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
   header {
-    padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
+    padding:12px 22px; border-bottom:1px solid var(--line); background:#fff;
     display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
   }
   header h1 { font:400 22px/1.2 var(--serif); margin:0; }
   header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
-  header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
+  header .stats { margin-left:auto; display:flex; gap:16px; font:600 12px ui-monospace,Menlo,monospace; }
   header .stats span b { color:var(--ink); }
-  header .stats .fp b { color:var(--green); }
-  header .stats .tp b { color:var(--red); }
-  header .stats .sk b { color:var(--faint); }
+  header .stats .clean b { color:var(--green); }
+  header .stats .ghost b { color:var(--red); }
+  header .stats .seam  b { color:var(--orange); }
+  header .stats .sk b   { color:var(--faint); }
 
-  main { max-width:1500px; margin:0 auto; padding:18px 18px 80px; }
+  main { max-width:1500px; margin:0 auto; padding:14px 18px 80px; }
 
   .controls {
-    display:flex; gap:16px; align-items:center; padding:10px 14px;
+    display:flex; gap:14px; align-items:center; padding:9px 14px;
     background:#fff; border:1px solid var(--line); border-radius:8px;
-    margin-bottom:14px; flex-wrap:wrap;
+    margin-bottom:12px; flex-wrap:wrap; font-size:12px;
   }
   .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
                     text-transform:uppercase; color:var(--faint); }
-  .controls select { padding:6px 10px; border:1px solid var(--line);
-                     border-radius:4px; background:#fff; font:13px var(--sans); }
-  .controls input[type=range] { vertical-align:middle; width:180px; }
-  .controls .density-val { font:600 12px ui-monospace,Menlo,monospace;
+  .controls select { padding:5px 9px; border:1px solid var(--line);
+                     border-radius:4px; background:#fff; font:12px var(--sans); }
+  .controls input[type=range] { vertical-align:middle; width:160px; }
+  .controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
                            color:var(--faint); min-width:32px; display:inline-block; }
   .controls .legend { margin-left:auto; font:11px var(--sans); color:var(--faint); }
   .controls kbd {
@@ -50,7 +51,7 @@
   .grid {
     display:grid;
     grid-template-columns:repeat(auto-fill,minmax(var(--card-min,200px),1fr));
-    gap:10px;
+    gap:8px;
   }
   .tile {
     position:relative; aspect-ratio:1; background:#f3eee2;
@@ -71,70 +72,132 @@
     font:600 10px ui-monospace,Menlo,monospace;
     padding:2px 5px; border-radius:3px;
   }
-  .tile.labeled-fp { outline:3px solid var(--green); outline-offset:-3px; }
-  .tile.labeled-fp::after {
-    content:'✓ CLEAN'; position:absolute; inset:auto 0 0 0;
-    background:var(--green); color:#fff; font:700 10px var(--sans);
-    letter-spacing:.1em; text-align:center; padding:4px 0;
+  .tile.lbl-fp       { outline:3px solid var(--green);  outline-offset:-3px; }
+  .tile.lbl-tp_ghost { outline:3px solid var(--red);    outline-offset:-3px; }
+  .tile.lbl-tp_seam  { outline:3px solid var(--orange); outline-offset:-3px; }
+  .tile.lbl-tp_other { outline:3px solid var(--gold);   outline-offset:-3px; }
+  .tile.lbl-skip     { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
+  .tile .verdict-pill {
+    position:absolute; left:0; right:0; bottom:0;
+    color:#fff; font:700 10px var(--sans); letter-spacing:.1em;
+    text-align:center; padding:4px 0; pointer-events:none;
   }
-  .tile.labeled-tp { outline:3px solid var(--red); outline-offset:-3px; }
-  .tile.labeled-tp::after {
-    content:'⚠ GHOST'; position:absolute; inset:auto 0 0 0;
-    background:var(--red); color:#fff; font:700 10px var(--sans);
-    letter-spacing:.1em; text-align:center; padding:4px 0;
-  }
-  .tile.labeled-skip { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
+  .tile.lbl-fp       .verdict-pill { background:var(--green);  display:block; content:'✓ CLEAN'; }
+  .tile.lbl-tp_ghost .verdict-pill { background:var(--red);    display:block; }
+  .tile.lbl-tp_seam  .verdict-pill { background:var(--orange); display:block; }
+  .tile.lbl-tp_other .verdict-pill { background:var(--gold);   display:block; }
+  .tile:not(.lbl-fp):not(.lbl-tp_ghost):not(.lbl-tp_seam):not(.lbl-tp_other) .verdict-pill { display:none; }
 
-  /* Detail modal */
+  /* Modal */
   .modal {
-    position:fixed; inset:0; background:rgba(20,15,5,.84);
+    position:fixed; inset:0; background:rgba(20,15,5,.92);
     display:none; align-items:stretch; justify-content:center; z-index:50;
   }
   .modal.open { display:flex; }
   .modal-inner {
-    display:grid; grid-template-columns:1fr 360px; gap:0;
-    background:#fff; width:100%; max-width:1280px; margin:auto;
-    border-radius:10px; overflow:hidden; max-height:94vh;
+    display:grid; grid-template-columns:1fr 340px; gap:0;
+    background:#fff; width:100%; max-width:1400px; margin:auto;
+    border-radius:10px; overflow:hidden; max-height:96vh; height:96vh;
   }
   .stage {
-    background:#000; position:relative; display:flex; align-items:center;
-    justify-content:center; overflow:hidden; min-height:520px;
+    background:#0a0a0a; position:relative; overflow:hidden;
+    display:flex; align-items:center; justify-content:center;
+    cursor:crosshair;
+    user-select:none;
   }
   .stage img.main {
-    max-width:100%; max-height:94vh; object-fit:contain; display:block;
-    user-select:none; -webkit-user-drag:none;
+    max-width:100%; max-height:96vh; object-fit:contain; display:block;
+    user-select:none; -webkit-user-drag:none; pointer-events:none;
+  }
+  .stage .img-wrap {
+    position:relative; display:inline-block; max-width:100%; max-height:96vh;
   }
-  .stage .cropbox {
-    position:absolute; border:2px solid #ff5252; background:rgba(255,82,82,.18);
-    pointer-events:none;
+  .stage .rect {
+    position:absolute; box-sizing:border-box; pointer-events:auto;
   }
-  .stage .cropbox::after {
-    content:'CROP'; position:absolute; top:-22px; left:0;
-    background:#ff5252; color:#fff; font:700 10px var(--sans);
-    letter-spacing:.1em; padding:2px 6px; border-radius:3px;
+  .stage .rect.r-ghost { border:2px solid #ff5252; background:rgba(255,82,82,.18); }
+  .stage .rect.r-seam  { border:2px solid #ffae3a; background:rgba(255,174,58,.20); }
+  .stage .rect.r-other { border:2px solid #ffd54a; background:rgba(255,213,74,.20); }
+  .stage .rect .label {
+    position:absolute; top:-22px; left:0;
+    color:#fff; font:700 10px var(--sans);
+    letter-spacing:.08em; padding:2px 6px; border-radius:3px;
+    pointer-events:none; white-space:nowrap;
   }
-  .stage .hint {
-    position:absolute; top:12px; left:12px;
-    background:rgba(0,0,0,.66); color:#fff; font:11px var(--sans);
-    padding:6px 10px; border-radius:4px; line-height:1.4;
+  .stage .rect.r-ghost .label { background:#ff5252; }
+  .stage .rect.r-seam  .label { background:#ffae3a; color:#3a2a00; }
+  .stage .rect.r-other .label { background:#ffd54a; color:#3a2a00; }
+  .stage .rect .x {
+    position:absolute; top:-10px; right:-10px;
+    width:22px; height:22px; border-radius:50%;
+    background:#fff; color:var(--ink); border:1px solid var(--line);
+    font:700 13px var(--sans); display:flex; align-items:center; justify-content:center;
+    cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.4);
   }
-  .stage .navbtn {
+  .stage .rect .x:hover { background:var(--red); color:#fff; border-color:var(--red); }
+  .stage .drag-rect {
+    position:absolute; box-sizing:border-box; pointer-events:none;
+    border:2px dashed #fff; background:rgba(255,255,255,.10);
+  }
+  .stage .nav {
     position:absolute; top:50%; transform:translateY(-50%);
-    background:rgba(0,0,0,.5); color:#fff; border:none;
-    width:44px; height:60px; font-size:22px; cursor:pointer;
+    background:rgba(0,0,0,.55); color:#fff; border:none;
+    width:42px; height:60px; font-size:22px; cursor:pointer; z-index:3;
+  }
+  .stage .nav:hover { background:rgba(0,0,0,.85); }
+  .stage .nav.prev { left:0; border-radius:0 6px 6px 0; }
+  .stage .nav.next { right:0; border-radius:6px 0 0 6px; }
+  .stage .top-hint {
+    position:absolute; top:10px; left:50%; transform:translateX(-50%);
+    background:rgba(0,0,0,.7); color:#fff; font:600 11px var(--sans);
+    padding:6px 14px; border-radius:18px; letter-spacing:.04em;
+    pointer-events:none; z-index:3;
+  }
+  .stage .pos-hint {
+    position:absolute; top:10px; right:10px;
+    background:rgba(0,0,0,.7); color:#fff; font:600 11px ui-monospace,Menlo,monospace;
+    padding:5px 10px; border-radius:4px; pointer-events:none; z-index:3;
+  }
+  .stage .seam-test-btn {
+    position:absolute; bottom:14px; right:14px; z-index:4;
+    background:rgba(0,0,0,.75); color:#fff; border:1px solid #555;
+    padding:9px 14px; border-radius:6px; cursor:pointer;
+    font:600 12px var(--sans); letter-spacing:.06em;
+    display:flex; align-items:center; gap:8px;
+  }
+  .stage .seam-test-btn:hover { background:#000; }
+  .stage .seam-test-btn.active { background:var(--orange); border-color:var(--orange); color:#fff; }
+  .stage .seam-test-btn .icon { font-size:14px; }
+  .stage canvas.seam-canvas {
+    max-width:100%; max-height:96vh; display:block;
+    user-select:none; pointer-events:none;
+    image-rendering:pixelated;
+  }
+  .stage .seam-cross {
+    position:absolute; pointer-events:none;
+    border:1px dashed rgba(255,174,58,.6); box-sizing:border-box;
   }
-  .stage .navbtn:hover { background:rgba(0,0,0,.8); }
-  .stage .navbtn.prev { left:0; border-radius:0 6px 6px 0; }
-  .stage .navbtn.next { right:0; border-radius:6px 0 0 6px; }
 
-  .panel { padding:18px 20px; display:flex; flex-direction:column; gap:14px; overflow-y:auto; }
-  .panel h3 { font:600 13px var(--sans); letter-spacing:.1em;
+  /* Right panel */
+  .panel {
+    padding:14px 18px; display:flex; flex-direction:column; gap:12px;
+    overflow-y:auto; background:#fcfaf4;
+  }
+  .panel .closex {
+    align-self:flex-end; background:none; border:none;
+    color:var(--faint); cursor:pointer; font-size:22px; line-height:1; padding:0;
+    margin-top:-4px;
+  }
+  .panel h3 { font:600 11px var(--sans); letter-spacing:.12em;
               text-transform:uppercase; color:var(--faint); margin:0; }
-  .panel .id-row { font:600 16px ui-monospace,Menlo,monospace; }
-  .panel .cat { font:13px var(--sans); color:var(--faint); }
+  .panel .id-row { font:600 17px ui-monospace,Menlo,monospace; }
+  .panel .id-row .pos {
+    font:11px var(--sans); color:var(--faint); margin-left:8px; letter-spacing:0;
+  }
+  .panel .cat { font:13px var(--sans); color:var(--ink); }
   .panel .reason {
     background:#fdf6e6; border-left:3px solid var(--gold);
-    padding:10px 12px; font:13px/1.5 var(--sans); color:var(--ink);
+    padding:9px 11px; font:12.5px/1.45 var(--sans); color:var(--ink);
     border-radius:0 4px 4px 0;
   }
   .panel .conf-bar {
@@ -142,36 +205,61 @@
     font:11px ui-monospace,Menlo,monospace; color:var(--faint);
   }
   .panel .conf-bar .bar {
-    flex:1; height:8px; background:#f3eee2; border-radius:4px; overflow:hidden;
+    flex:1; height:7px; background:#f3eee2; border-radius:4px; overflow:hidden;
   }
   .panel .conf-bar .bar-fill { height:100%; background:var(--red); }
-  .panel .actions { display:flex; flex-direction:column; gap:8px; margin-top:6px; }
+  .panel .mode-row {
+    display:flex; gap:6px; padding:4px; background:#f3eee2; border-radius:6px;
+  }
+  .panel .mode-row button {
+    flex:1; padding:7px 0; border:none; background:none; cursor:pointer;
+    font:600 11px var(--sans); letter-spacing:.06em; color:var(--faint);
+    border-radius:4px; text-transform:uppercase;
+  }
+  .panel .mode-row button.active.m-ghost { background:#ff5252; color:#fff; }
+  .panel .mode-row button.active.m-seam  { background:#ffae3a; color:#3a2a00; }
+  .panel .mode-row button.active.m-other { background:#ffd54a; color:#3a2a00; }
+  .panel .rect-count {
+    text-align:center; font:600 12px ui-monospace,Menlo,monospace; color:var(--faint);
+  }
+  .panel .rect-count b.has { color:var(--red); }
+  .panel .actions { display:flex; flex-direction:column; gap:7px; margin-top:4px; }
   .panel .actions button {
-    padding:12px 14px; border:1px solid var(--line); border-radius:6px;
-    background:#fff; cursor:pointer; font:600 13px var(--sans);
-    display:flex; align-items:center; gap:10px; text-align:left;
+    padding:13px 14px; border:1px solid var(--line); border-radius:6px;
+    background:#fff; cursor:pointer; font:700 13px var(--sans);
+    display:flex; align-items:center; justify-content:space-between; gap:10px;
+    transition:transform .08s;
   }
+  .panel .actions button:active { transform:scale(0.98); }
   .panel .actions button kbd {
     background:#f3eee2; border:1px solid var(--line); border-radius:3px;
-    padding:1px 6px; font:600 11px ui-monospace,Menlo,monospace; margin-left:auto;
-  }
-  .panel .actions .b-fp { border-color:var(--green); }
-  .panel .actions .b-fp:hover { background:#eaf4ec; }
-  .panel .actions .b-tp { border-color:var(--red); }
-  .panel .actions .b-tp:hover { background:#fce8e6; }
-  .panel .actions .b-tp.armed { background:#ffeaea; border-width:2px; }
-  .panel .actions .b-skip { color:var(--faint); }
-  .panel .crop-readout {
-    font:11px ui-monospace,Menlo,monospace; color:var(--faint);
-    background:#f3eee2; padding:6px 8px; border-radius:4px;
+    padding:2px 7px; font:700 11px ui-monospace,Menlo,monospace;
   }
-  .panel .clear-crop {
+  .panel .actions .b-fp    { border-color:var(--green);  color:var(--green); }
+  .panel .actions .b-fp:hover    { background:var(--green);  color:#fff; }
+  .panel .actions .b-ghost { border-color:var(--red);    color:var(--red); }
+  .panel .actions .b-ghost:hover { background:var(--red);    color:#fff; }
+  .panel .actions .b-seam  { border-color:var(--orange); color:var(--orange); }
+  .panel .actions .b-seam:hover  { background:var(--orange); color:#fff; }
+  .panel .actions .b-other { border-color:var(--gold); color:#7a5500; }
+  .panel .actions .b-other:hover { background:var(--gold); color:#fff; }
+  .panel .actions .b-skip  { color:var(--faint); }
+  .panel .actions .b-ghost.gated,
+  .panel .actions .b-seam.gated,
+  .panel .actions .b-other.gated { opacity:.45; cursor:not-allowed; }
+  .panel .undo-row {
+    display:flex; gap:6px; font:11px var(--sans); color:var(--faint); align-items:center;
+  }
+  .panel .undo-row button {
     background:none; border:none; color:var(--faint); cursor:pointer;
-    font:11px var(--sans); text-decoration:underline; padding:0;
+    text-decoration:underline; font:11px var(--sans); padding:0;
   }
-  .panel .closex {
-    align-self:flex-end; background:none; border:none;
-    color:var(--faint); cursor:pointer; font-size:22px; line-height:1;
+  .panel .notes-row {
+    display:flex; flex-direction:column; gap:4px;
+  }
+  .panel .notes-row textarea {
+    border:1px solid var(--line); border-radius:4px; padding:6px 8px;
+    font:12px var(--sans); resize:vertical; min-height:48px;
   }
 
   .empty {
@@ -180,23 +268,84 @@
   }
   .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
 
-  .filter-bar { display:flex; gap:8px; flex-wrap:wrap; }
+  .filter-bar { display:flex; gap:6px; flex-wrap:wrap; }
   .filter-bar button {
     border:1px solid var(--line); background:#fff; border-radius:14px;
-    padding:5px 11px; cursor:pointer; font:600 11px var(--sans);
+    padding:4px 10px; cursor:pointer; font:600 11px var(--sans);
     color:var(--faint); letter-spacing:.04em;
   }
   .filter-bar button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
+
+  .toast {
+    position:fixed; bottom:30px; left:50%; transform:translateX(-50%);
+    background:rgba(31,24,8,.95); color:#fff; padding:10px 18px;
+    border-radius:6px; font:600 13px var(--sans);
+    opacity:0; transition:opacity .2s; pointer-events:none; z-index:200;
+  }
+  .toast.show { opacity:1; }
+
+  /* Live analysis panel */
+  .analysis {
+    background:#fff; border:1px solid var(--line); border-radius:8px;
+    padding:12px 16px; margin-bottom:12px;
+    display:none;  /* shown once labels >= 5 */
+  }
+  .analysis.open { display:block; }
+  .analysis .head {
+    display:flex; align-items:baseline; gap:10px; margin-bottom:10px;
+  }
+  .analysis .head h2 {
+    margin:0; font:600 11px var(--sans); letter-spacing:.16em;
+    text-transform:uppercase; color:var(--faint);
+  }
+  .analysis .head .live {
+    font:600 10px var(--sans); color:var(--green); letter-spacing:.1em;
+  }
+  .analysis .head .toggle {
+    margin-left:auto; font:11px var(--sans); color:var(--faint);
+    cursor:pointer; background:none; border:none; text-decoration:underline;
+  }
+  .analysis-grid {
+    display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));
+    gap:14px;
+  }
+  .ana-card {
+    border:1px solid var(--line); border-radius:6px; padding:10px 12px;
+    background:#fcfaf4;
+  }
+  .ana-card h3 {
+    margin:0 0 6px 0; font:600 10px var(--sans); letter-spacing:.12em;
+    text-transform:uppercase; color:var(--faint);
+  }
+  .ana-card .big { font:700 22px ui-monospace,Menlo,monospace; }
+  .ana-card .sub { font:11px var(--sans); color:var(--faint); margin-top:2px; }
+  .ana-card .bar {
+    height:6px; background:#f3eee2; border-radius:3px; overflow:hidden; margin:6px 0;
+  }
+  .ana-card .bar-fill { height:100%; }
+  .ana-card .bf-good { background:var(--green); }
+  .ana-card .bf-bad  { background:var(--red); }
+  .ana-card .bf-mid  { background:var(--orange); }
+  .ana-card table { width:100%; font:11px var(--sans); border-collapse:collapse; }
+  .ana-card td { padding:2px 0; }
+  .ana-card td.r { text-align:right; font-family:ui-monospace,Menlo,monospace; color:var(--faint); }
+  .ana-card td.n { width:42px; text-align:right; color:var(--faint); }
+  .ana-card .mini-bar {
+    display:inline-block; height:5px; background:#f3eee2; border-radius:2px;
+    vertical-align:middle; margin-right:5px; width:60px; overflow:hidden;
+  }
+  .ana-card .mini-bar .mbf { display:block; height:100%; background:var(--red); }
 </style>
 </head>
 <body>
 
 <header>
   <h1>Ghost Review</h1>
-  <span class="meta">label false-positives to train the detector</span>
+  <span class="meta">draw rects · pick a defect · go fast</span>
   <div class="stats">
-    <span class="fp">CLEAN <b id="stat-fp">0</b></span>
-    <span class="tp">GHOST <b id="stat-tp">0</b></span>
+    <span class="clean">CLEAN <b id="stat-fp">0</b></span>
+    <span class="ghost">GHOST <b id="stat-ghost">0</b></span>
+    <span class="seam">SEAM <b id="stat-seam">0</b></span>
     <span class="sk">SKIP <b id="stat-skip">0</b></span>
     <span>UNLABELED <b id="stat-todo">—</b></span>
     <span>TOTAL <b id="stat-total">—</b></span>
@@ -220,18 +369,30 @@
     <div class="filter-bar">
       <button class="active" data-filter="unlabeled">Unlabeled</button>
       <button data-filter="fp">Clean</button>
-      <button data-filter="tp">Ghost</button>
+      <button data-filter="ghost">Ghost</button>
+      <button data-filter="seam">Seam</button>
       <button data-filter="all">All</button>
     </div>
     <div class="legend">
-      <kbd>F</kbd> false-positive (clean) &nbsp;
-      <kbd>T</kbd> true-positive (ghost) &nbsp;
-      <kbd>S</kbd> skip &nbsp;
-      <kbd>←</kbd> <kbd>→</kbd> navigate &nbsp;
-      <kbd>Esc</kbd> close
+      <kbd>F</kbd> clean
+      &nbsp;<kbd>G</kbd> ghost
+      &nbsp;<kbd>B</kbd> seam-break
+      &nbsp;<kbd>S</kbd> skip
+      &nbsp;<kbd>U</kbd> undo rect
+      &nbsp;<kbd>←</kbd><kbd>→</kbd> nav
+      &nbsp;<kbd>Esc</kbd> close
     </div>
   </div>
 
+  <section class="analysis" id="analysis">
+    <div class="head">
+      <h2>Live Detector Analysis</h2>
+      <span class="live">● updates as you label</span>
+      <button class="toggle" id="ana-toggle">hide</button>
+    </div>
+    <div class="analysis-grid" id="ana-grid"></div>
+  </section>
+
   <div id="grid" class="grid"></div>
   <div id="empty" class="empty" style="display:none">
     <h2>Nothing to review</h2>
@@ -239,19 +400,24 @@
   </div>
 </main>
 
-<div class="modal" id="modal">
+<div class="modal" id="modal" aria-hidden="true">
   <div class="modal-inner">
     <div class="stage" id="stage">
-      <button class="navbtn prev" id="prev-btn">‹</button>
-      <img class="main" id="main-img" alt="">
-      <div class="cropbox" id="cropbox" style="display:none"></div>
-      <div class="hint" id="hint" style="display:none">Drag to mark the ghost region — release to confirm</div>
-      <button class="navbtn next" id="next-btn">›</button>
+      <button class="nav prev" id="prev-btn" title="prev (←)">‹</button>
+      <div class="img-wrap" id="img-wrap">
+        <img class="main" id="main-img" alt="">
+        <canvas class="seam-canvas" id="seam-canvas" style="display:none"></canvas>
+      </div>
+      <button class="nav next" id="next-btn" title="next (→)">›</button>
+      <div class="top-hint" id="top-hint">Drag to mark defect regions — then press <b>G</b> ghost or <b>B</b> seam-break</div>
+      <div class="pos-hint" id="pos-hint">—</div>
+      <button class="seam-test-btn" id="seam-test-btn" title="quadrant-swap to bring outer edges to the center (X)">
+        <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>
+      </button>
     </div>
     <div class="panel">
       <button class="closex" id="close-btn" title="close (esc)">×</button>
-      <h3>Design</h3>
-      <div class="id-row">#<span id="d-id">—</span></div>
+      <div class="id-row">#<span id="d-id">—</span><span class="pos" id="d-pos"></span></div>
       <div class="cat" id="d-cat">—</div>
       <h3>Detector's reason</h3>
       <div class="reason" id="d-reason">—</div>
@@ -260,37 +426,58 @@
         <div class="bar"><div class="bar-fill" id="d-conf-fill" style="width:0%"></div></div>
         <span id="d-conf-num">0.00</span>
       </div>
+
+      <h3>Defect type (for drawing)</h3>
+      <div class="mode-row">
+        <button class="m-ghost active" data-mode="ghost">Ghost</button>
+        <button class="m-seam"        data-mode="seam">Seam-break</button>
+        <button class="m-other"       data-mode="other">Other</button>
+      </div>
+      <div class="rect-count" id="rect-count">no regions marked yet</div>
+      <div class="undo-row">
+        <button id="undo-btn">↶ undo last rect (U)</button>
+        <span style="margin-left:auto">·</span>
+        <button id="clear-btn">clear all</button>
+      </div>
+
       <h3>Verdict</h3>
       <div class="actions">
-        <button class="b-fp" id="btn-fp">✓ False positive — image is clean <kbd>F</kbd></button>
-        <button class="b-tp" id="btn-tp">⚠ True positive — drag crop, then save <kbd>T</kbd></button>
-        <button class="b-skip" id="btn-skip">– Skip for now <kbd>S</kbd></button>
+        <button class="b-fp"    id="btn-fp">✓ Clean (false-positive) <kbd>F</kbd></button>
+        <button class="b-ghost gated" id="btn-ghost">⚠ Ghost-layer · <span id="bn-ghost">0</span> regions <kbd>G</kbd></button>
+        <button class="b-seam  gated" id="btn-seam">⤬ Seam-break · <span id="bn-seam">0</span> regions <kbd>B</kbd></button>
+        <button class="b-other gated" id="btn-other">◆ Other defect · <span id="bn-other">0</span> regions <kbd>O</kbd></button>
+        <button class="b-skip" id="btn-skip">– Skip <kbd>S</kbd></button>
+      </div>
+      <div class="notes-row" id="notes-row" style="display:none">
+        <label style="font:11px var(--sans); color:var(--faint)">Notes for "other" defect:</label>
+        <textarea id="notes" placeholder="describe the defect…"></textarea>
       </div>
-      <div class="crop-readout" id="crop-readout" style="display:none"></div>
-      <button class="clear-crop" id="clear-crop" style="display:none">clear crop</button>
     </div>
   </div>
 </div>
 
+<div class="toast" id="toast"></div>
+
 <script>
 (() => {
-  const STAT = { fp:0, tp:0, skip:0, todo:0, total:0 };
   const $ = (id) => document.getElementById(id);
+  const STAT = { fp:0, ghost:0, seam:0, other:0, skip:0, total:0 };
 
   // State
-  let ALL = [];                    // every flagged item from server
-  const LABELS = new Map();        // id -> { verdict, crop? }
-  let CURRENT = null;              // currently-open item index in VIEW
-  let VIEW = [];                   // filtered + sorted view
+  let ALL = [];
+  const LABELS = new Map();       // id -> { verdict, crops }
+  let VIEW = [];
+  let CURRENT = null;
   let FILTER = 'unlabeled';
   let CAT_FILTER = '';
   let SORT = 'conf-desc';
-  let ARMED_TP = false;            // true after T pressed, waiting for crop
-  let PENDING_CROP = null;         // {x,y,w,h} normalized [0..1]
+
+  // Per-item drawing state
+  let RECTS = [];                 // [{x,y,w,h,tag}] normalized [0..1]
+  let DRAW_MODE = 'ghost';        // 'ghost' | 'seam' | 'other'
 
   // Density slider
-  const density = $('density');
-  const densityVal = $('density-val');
+  const density = $('density'), densityVal = $('density-val');
   density.value = localStorage.getItem('gr-density') || 200;
   densityVal.textContent = density.value + 'px';
   document.documentElement.style.setProperty('--card-min', density.value + 'px');
@@ -300,17 +487,15 @@
     localStorage.setItem('gr-density', density.value);
   };
 
-  // Sort + filter wiring
   $('filter-sort').value = localStorage.getItem('gr-sort') || 'conf-desc';
-  $('filter-sort').onchange = (e) => { SORT = e.target.value; localStorage.setItem('gr-sort', SORT); recomputeView(); render(); };
-  $('filter-cat').onchange = (e) => { CAT_FILTER = e.target.value; recomputeView(); render(); };
+  $('filter-sort').onchange = e => { SORT = e.target.value; localStorage.setItem('gr-sort', SORT); recomputeView(); render(); };
+  $('filter-cat').onchange  = e => { CAT_FILTER = e.target.value; recomputeView(); render(); };
   document.querySelectorAll('.filter-bar button').forEach(b => {
     b.onclick = () => {
       document.querySelectorAll('.filter-bar button').forEach(x => x.classList.remove('active'));
       b.classList.add('active');
       FILTER = b.dataset.filter;
-      recomputeView();
-      render();
+      recomputeView(); render();
     };
   });
 
@@ -319,22 +504,153 @@
       if (CAT_FILTER && item.category !== CAT_FILTER) return false;
       const lbl = LABELS.get(item.id);
       if (FILTER === 'unlabeled') return !lbl;
-      if (FILTER === 'fp') return lbl && lbl.verdict === 'fp';
-      if (FILTER === 'tp') return lbl && lbl.verdict === 'tp';
-      return true; // all
+      if (FILTER === 'fp')        return lbl && lbl.verdict === 'fp';
+      if (FILTER === 'ghost')     return lbl && lbl.verdict === 'tp_ghost';
+      if (FILTER === 'seam')      return lbl && lbl.verdict === 'tp_seam';
+      return true;
     });
-    if (SORT === 'conf-desc') VIEW.sort((a,b) => (b.confidence||0) - (a.confidence||0));
+    if (SORT === 'conf-desc')      VIEW.sort((a,b) => (b.confidence||0) - (a.confidence||0));
     else if (SORT === 'conf-asc')  VIEW.sort((a,b) => (a.confidence||0) - (b.confidence||0));
     else if (SORT === 'id-desc')   VIEW.sort((a,b) => b.id - a.id);
     else if (SORT === 'id-asc')    VIEW.sort((a,b) => a.id - b.id);
   }
 
   function updateStats() {
-    $('stat-fp').textContent = STAT.fp;
-    $('stat-tp').textContent = STAT.tp;
-    $('stat-skip').textContent = STAT.skip;
+    $('stat-fp').textContent    = STAT.fp;
+    $('stat-ghost').textContent = STAT.ghost;
+    $('stat-seam').textContent  = STAT.seam;
+    $('stat-skip').textContent  = STAT.skip;
     $('stat-total').textContent = STAT.total.toLocaleString();
-    $('stat-todo').textContent = (STAT.total - STAT.fp - STAT.tp - STAT.skip).toLocaleString();
+    const labeled = STAT.fp + STAT.ghost + STAT.seam + STAT.other + STAT.skip;
+    $('stat-todo').textContent  = (STAT.total - labeled).toLocaleString();
+    renderAnalysis();
+  }
+
+  // ── Live analysis (downtime use) ───────────────────────────────────────
+  let analysisHidden = (localStorage.getItem('gr-ana-hidden') === '1');
+  $('ana-toggle').onclick = () => {
+    analysisHidden = !analysisHidden;
+    localStorage.setItem('gr-ana-hidden', analysisHidden ? '1' : '0');
+    renderAnalysis();
+  };
+  function renderAnalysis() {
+    const labeledItems = ALL.filter(i => LABELS.has(i.id));
+    const decided = labeledItems.filter(i => {
+      const v = LABELS.get(i.id).verdict;
+      return v === 'fp' || v === 'tp_ghost' || v === 'tp_seam' || v === 'tp_other' || v === 'tp';
+    });
+    const ana = $('analysis');
+    if (decided.length < 5 || analysisHidden) {
+      if (decided.length < 5) ana.classList.remove('open');
+      else if (analysisHidden) {
+        ana.classList.add('open');
+        $('ana-grid').innerHTML = `<div class="ana-card"><h3>Analysis</h3><div class="sub">hidden — <button class="toggle" onclick="document.getElementById('ana-toggle').click()">show</button></div></div>`;
+        $('ana-toggle').textContent = 'show';
+        return;
+      }
+      return;
+    }
+    ana.classList.add('open');
+    $('ana-toggle').textContent = 'hide';
+
+    // Detector precision = TP / (TP+FP) — only items where there IS a real defect
+    // (Steve's labels). Detector "positive" = every item in our flagged set, so
+    // precision-of-detector = (decided that are TP) / decided
+    const tpCount = decided.filter(i => LABELS.get(i.id).verdict !== 'fp').length;
+    const fpCount = decided.length - tpCount;
+    const precision = tpCount / decided.length;
+    const fpRate    = fpCount / decided.length;
+
+    // Defect-type split among true positives
+    const ghostN = decided.filter(i => LABELS.get(i.id).verdict === 'tp_ghost' || LABELS.get(i.id).verdict === 'tp').length;
+    const seamN  = decided.filter(i => LABELS.get(i.id).verdict === 'tp_seam').length;
+    const otherN = decided.filter(i => LABELS.get(i.id).verdict === 'tp_other').length;
+
+    // Per-confidence-band FP rate
+    const bands = [
+      { name: '0.50–0.69', min: 0.5, max: 0.7 },
+      { name: '0.70–0.89', min: 0.7, max: 0.9 },
+      { name: '0.90–1.00', min: 0.9, max: 1.01 },
+    ];
+    for (const b of bands) {
+      const inb = decided.filter(i => i.confidence >= b.min && i.confidence < b.max);
+      b.n  = inb.length;
+      b.fp = inb.filter(i => LABELS.get(i.id).verdict === 'fp').length;
+      b.fpRate = inb.length ? (b.fp / inb.length) : 0;
+    }
+
+    // Per-category FP rate (top 6 by labeled count)
+    const byCat = new Map();
+    for (const it of decided) {
+      const c = it.category || '—';
+      const v = LABELS.get(it.id).verdict;
+      const r = byCat.get(c) || { n: 0, fp: 0 };
+      r.n++;
+      if (v === 'fp') r.fp++;
+      byCat.set(c, r);
+    }
+    const topCats = [...byCat.entries()]
+      .map(([c, r]) => ({ c, n: r.n, fp: r.fp, fpRate: r.n ? r.fp / r.n : 0 }))
+      .sort((a,b) => b.n - a.n).slice(0, 6);
+
+    // Render
+    const precClass = precision >= 0.7 ? 'bf-good' : precision >= 0.4 ? 'bf-mid' : 'bf-bad';
+    const grid = $('ana-grid');
+    grid.innerHTML = `
+      <div class="ana-card">
+        <h3>Detector Precision</h3>
+        <div class="big">${(precision*100).toFixed(1)}%</div>
+        <div class="bar"><div class="bar-fill ${precClass}" style="width:${precision*100}%"></div></div>
+        <div class="sub">of ${decided.length} labeled — ${tpCount} true / ${fpCount} false</div>
+      </div>
+      <div class="ana-card">
+        <h3>False-Positive Rate</h3>
+        <div class="big" style="color:${fpRate >= 0.5 ? 'var(--red)' : 'var(--ink)'}">${(fpRate*100).toFixed(1)}%</div>
+        <div class="bar"><div class="bar-fill bf-bad" style="width:${fpRate*100}%"></div></div>
+        <div class="sub">${fpCount >= 100 ? 'enough signal to refine the prompt' : `need ~${100 - fpCount} more FP labels for prompt refinement`}</div>
+      </div>
+      <div class="ana-card">
+        <h3>Defect Type Mix</h3>
+        <table>
+          <tr><td>⚠ ghost-layer</td><td class="r">${ghostN}</td></tr>
+          <tr><td>⤬ seam-break</td><td class="r">${seamN}</td></tr>
+          <tr><td>◆ other</td><td class="r">${otherN}</td></tr>
+        </table>
+        <div class="sub">${(ghostN + seamN + otherN)} true defects out of ${decided.length} labeled</div>
+      </div>
+      <div class="ana-card">
+        <h3>FP-Rate by Confidence Band</h3>
+        <table>
+          ${bands.map(b => `
+            <tr>
+              <td>${b.name}</td>
+              <td><span class="mini-bar"><span class="mbf" style="width:${b.fpRate*100}%"></span></span> ${(b.fpRate*100).toFixed(0)}%</td>
+              <td class="n">${b.n}</td>
+            </tr>
+          `).join('')}
+        </table>
+        <div class="sub">if conf=0.9+ band still has high FP, the prompt is broken (not the threshold)</div>
+      </div>
+      <div class="ana-card">
+        <h3>FP-Rate by Category (top 6)</h3>
+        <table>
+          ${topCats.map(c => `
+            <tr>
+              <td title="${c.c}" style="max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${c.c}</td>
+              <td><span class="mini-bar"><span class="mbf" style="width:${c.fpRate*100}%"></span></span> ${(c.fpRate*100).toFixed(0)}%</td>
+              <td class="n">${c.n}</td>
+            </tr>
+          `).join('')}
+        </table>
+        <div class="sub">categories needing per-category prompt tweaks bubble up here</div>
+      </div>
+      <div class="ana-card">
+        <h3>Coverage Progress</h3>
+        <div class="big">${decided.length}/${STAT.total.toLocaleString()}</div>
+        <div class="bar"><div class="bar-fill bf-good" style="width:${(decided.length/STAT.total)*100}%"></div></div>
+        <div class="sub">${((decided.length/STAT.total)*100).toFixed(2)}% of flagged set labeled · ETA at current pace</div>
+      </div>
+    `;
   }
 
   function render() {
@@ -343,186 +659,353 @@
     const empty = $('empty');
     if (!VIEW.length) { empty.style.display = ''; return; }
     empty.style.display = 'none';
-    // virtual-ish: render first 300 then scroll-load more
-    const slice = VIEW.slice(0, 300);
+    const slice = VIEW.slice(0, 400);
+    const frag = document.createDocumentFragment();
     for (const item of slice) {
       const tile = document.createElement('div');
       tile.className = 'tile';
       const lbl = LABELS.get(item.id);
-      if (lbl) tile.classList.add('labeled-' + lbl.verdict);
+      if (lbl) tile.classList.add('lbl-' + lbl.verdict);
+      const pillText = lbl ?
+        (lbl.verdict === 'fp'       ? '✓ CLEAN' :
+         lbl.verdict === 'tp_ghost' ? '⚠ GHOST' :
+         lbl.verdict === 'tp_seam'  ? '⤬ SEAM' :
+         lbl.verdict === 'tp_other' ? '◆ OTHER' : '') : '';
       tile.innerHTML = `
         <img loading="lazy" src="${item.image_url}" alt="${item.id}">
         <div class="badge">${item.category || ''}</div>
         <div class="conf">${(item.confidence || 0).toFixed(2)}</div>
+        ${pillText ? `<div class="verdict-pill">${pillText}</div>` : ''}
       `;
       tile.onclick = () => open(VIEW.indexOf(item));
-      grid.appendChild(tile);
+      frag.appendChild(tile);
     }
+    grid.appendChild(frag);
   }
 
-  // --- Modal / detail view -----------------------------------------------
-  const modal = $('modal');
-  const mainImg = $('main-img');
-  const cropbox = $('cropbox');
-  const stage = $('stage');
+  // ── Modal ──────────────────────────────────────────────────────────────
+  const modal      = $('modal');
+  const stage      = $('stage');
+  const mainImg    = $('main-img');
+  const imgWrap    = $('img-wrap');
+  const seamCanvas = $('seam-canvas');
+  let SEAM_VIEW = false;
+  const activeImageEl = () => SEAM_VIEW ? seamCanvas : mainImg;
 
   function open(idx) {
     if (idx < 0 || idx >= VIEW.length) return;
     CURRENT = idx;
     const item = VIEW[idx];
     $('d-id').textContent = item.id;
+    $('d-pos').textContent = `(${idx + 1} of ${VIEW.length.toLocaleString()})`;
     $('d-cat').textContent = item.category || '—';
     $('d-reason').textContent = item.reason || '—';
     const c = (item.confidence || 0);
     $('d-conf-fill').style.width = (c * 100) + '%';
     $('d-conf-num').textContent = c.toFixed(2);
+    // Preload existing crops if already labeled
+    const lbl = LABELS.get(item.id);
+    RECTS = (lbl && Array.isArray(lbl.crops)) ?
+      lbl.crops.map(c => ({ ...c, tag: c.tag || (lbl.verdict === 'tp_seam' ? 'seam' : lbl.verdict === 'tp_other' ? 'other' : 'ghost') })) :
+      [];
+    DRAW_MODE = (lbl && lbl.verdict === 'tp_seam') ? 'seam'
+              : (lbl && lbl.verdict === 'tp_other') ? 'other'
+              : 'ghost';
+    updateModeButtons();
+    mainImg.onload = () => {
+      drawRects();
+      updateRectCount();
+      if (SEAM_VIEW) buildSeamCanvas();
+    };
     mainImg.src = item.image_url;
-    clearCrop();
+    if (mainImg.complete) {
+      drawRects();
+      updateRectCount();
+      if (SEAM_VIEW) buildSeamCanvas();
+    }
+    $('notes').value = (lbl && lbl.notes) || '';
+    $('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
     modal.classList.add('open');
   }
-
-  function close() {
-    modal.classList.remove('open');
-    CURRENT = null;
-    clearCrop();
-  }
-
-  function navigate(delta) {
+  function close() { modal.classList.remove('open'); CURRENT = null; }
+  function navigate(d) {
     if (CURRENT === null) return;
-    const next = CURRENT + delta;
+    const next = CURRENT + d;
     if (next < 0 || next >= VIEW.length) return;
     open(next);
   }
-
   $('close-btn').onclick = close;
   $('prev-btn').onclick = () => navigate(-1);
   $('next-btn').onclick = () => navigate(1);
 
-  // --- Crop drag-rect ----------------------------------------------------
-  let dragging = false, dragStart = null;
-  stage.onmousedown = (e) => {
-    if (e.target.classList.contains('navbtn')) return;
-    if (!ARMED_TP) return;  // only allow drag after T pressed (or via button)
-    const r = mainImg.getBoundingClientRect();
-    // ignore if outside image
-    if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
+  // Mode buttons
+  document.querySelectorAll('.mode-row button').forEach(b => {
+    b.onclick = () => {
+      DRAW_MODE = b.dataset.mode;
+      updateModeButtons();
+    };
+  });
+  function updateModeButtons() {
+    document.querySelectorAll('.mode-row button').forEach(b => {
+      b.classList.toggle('active', b.dataset.mode === DRAW_MODE);
+    });
+    $('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
+  }
+
+  // ── Drawing ────────────────────────────────────────────────────────────
+  let dragging = false, dragStart = null, dragGhost = null;
+  stage.addEventListener('mousedown', (e) => {
+    if (e.target.tagName === 'BUTTON') return;
+    if (e.target.classList.contains('x')) return;
+    if (CURRENT === null) return;
+    const wrapRect = imgWrap.getBoundingClientRect();
+    if (e.clientX < wrapRect.left || e.clientX > wrapRect.right ||
+        e.clientY < wrapRect.top  || e.clientY > wrapRect.bottom) return;
     dragging = true;
-    dragStart = { x: e.clientX, y: e.clientY, rect: r };
-    cropbox.style.display = '';
-    cropbox.style.left = (e.clientX - stage.getBoundingClientRect().left) + 'px';
-    cropbox.style.top  = (e.clientY - stage.getBoundingClientRect().top) + 'px';
-    cropbox.style.width = '0px';
-    cropbox.style.height = '0px';
+    dragStart = { x: e.clientX, y: e.clientY, wrap: wrapRect };
+    dragGhost = document.createElement('div');
+    dragGhost.className = 'drag-rect';
+    dragGhost.style.left = (e.clientX - wrapRect.left) + 'px';
+    dragGhost.style.top  = (e.clientY - wrapRect.top)  + 'px';
+    dragGhost.style.width = '0px';
+    dragGhost.style.height = '0px';
+    imgWrap.appendChild(dragGhost);
     e.preventDefault();
-  };
-  stage.onmousemove = (e) => {
+  });
+  document.addEventListener('mousemove', (e) => {
     if (!dragging) return;
-    const sr = stage.getBoundingClientRect();
-    const x = Math.min(e.clientX, dragStart.x);
-    const y = Math.min(e.clientY, dragStart.y);
-    const w = Math.abs(e.clientX - dragStart.x);
-    const h = Math.abs(e.clientY - dragStart.y);
-    cropbox.style.left = (x - sr.left) + 'px';
-    cropbox.style.top  = (y - sr.top) + 'px';
-    cropbox.style.width = w + 'px';
-    cropbox.style.height = h + 'px';
-  };
-  stage.onmouseup = (e) => {
+    const wr = dragStart.wrap;
+    const x = Math.max(wr.left,  Math.min(wr.right,  Math.min(e.clientX, dragStart.x)));
+    const y = Math.max(wr.top,   Math.min(wr.bottom, Math.min(e.clientY, dragStart.y)));
+    const xe = Math.max(wr.left, Math.min(wr.right,  Math.max(e.clientX, dragStart.x)));
+    const ye = Math.max(wr.top,  Math.min(wr.bottom, Math.max(e.clientY, dragStart.y)));
+    dragGhost.style.left = (x - wr.left) + 'px';
+    dragGhost.style.top  = (y - wr.top)  + 'px';
+    dragGhost.style.width  = (xe - x) + 'px';
+    dragGhost.style.height = (ye - y) + 'px';
+    $('pos-hint').textContent = `${Math.round(xe-x)}×${Math.round(ye-y)}px`;
+  });
+  document.addEventListener('mouseup', (e) => {
     if (!dragging) return;
     dragging = false;
-    const r = mainImg.getBoundingClientRect();
-    const x0 = Math.max(r.left,  Math.min(e.clientX, dragStart.x));
-    const y0 = Math.max(r.top,   Math.min(e.clientY, dragStart.y));
-    const x1 = Math.min(r.right, Math.max(e.clientX, dragStart.x));
-    const y1 = Math.min(r.bottom,Math.max(e.clientY, dragStart.y));
-    const nw = r.width, nh = r.height;
-    if ((x1 - x0) < 10 || (y1 - y0) < 10) { clearCrop(); return; }
-    PENDING_CROP = {
-      x: (x0 - r.left) / nw,
-      y: (y0 - r.top)  / nh,
-      w: (x1 - x0) / nw,
-      h: (y1 - y0) / nh,
-    };
-    showCropReadout();
-  };
+    const wr = dragStart.wrap;
+    const x0 = Math.max(wr.left,  Math.min(e.clientX, dragStart.x));
+    const y0 = Math.max(wr.top,   Math.min(e.clientY, dragStart.y));
+    const x1 = Math.min(wr.right, Math.max(e.clientX, dragStart.x));
+    const y1 = Math.min(wr.bottom,Math.max(e.clientY, dragStart.y));
+    if (dragGhost && dragGhost.parentNode) dragGhost.parentNode.removeChild(dragGhost);
+    dragGhost = null;
+    if ((x1 - x0) < 6 || (y1 - y0) < 6) return; // ignore micro-drags
+    // Use ACTIVE image element's rect (canvas in seam-view, img otherwise)
+    // so coords normalize to the visible frame, not the wrap padding.
+    const activeRect = activeImageEl().getBoundingClientRect();
+    if (activeRect.width <= 0) return;
+    RECTS.push({
+      x: (x0 - activeRect.left) / activeRect.width,
+      y: (y0 - activeRect.top)  / activeRect.height,
+      w: (x1 - x0) / activeRect.width,
+      h: (y1 - y0) / activeRect.height,
+      tag: DRAW_MODE,
+      view: SEAM_VIEW ? 'seam-quadrant-swap' : 'original',
+    });
+    drawRects();
+    updateRectCount();
+  });
+
+  function drawRects() {
+    // Remove existing rect elements
+    imgWrap.querySelectorAll('.rect, .seam-cross').forEach(el => el.remove());
+    const activeEl = activeImageEl();
+    const r = activeEl.getBoundingClientRect();
+    const wr = imgWrap.getBoundingClientRect();
+    const offX = r.left - wr.left, offY = r.top - wr.top;
+    // When in seam-view, draw a faint cross at the center to mark the seam-join location
+    if (SEAM_VIEW && r.width > 0) {
+      const vline = document.createElement('div');
+      vline.className = 'seam-cross';
+      vline.style.left = (offX + r.width / 2) + 'px';
+      vline.style.top  = offY + 'px';
+      vline.style.width = '0px';
+      vline.style.height = r.height + 'px';
+      vline.style.borderLeft = '1px dashed rgba(255,174,58,.55)';
+      imgWrap.appendChild(vline);
+      const hline = document.createElement('div');
+      hline.className = 'seam-cross';
+      hline.style.left = offX + 'px';
+      hline.style.top  = (offY + r.height / 2) + 'px';
+      hline.style.width = r.width + 'px';
+      hline.style.height = '0px';
+      hline.style.borderTop = '1px dashed rgba(255,174,58,.55)';
+      imgWrap.appendChild(hline);
+    }
+    RECTS.forEach((rc, i) => {
+      const el = document.createElement('div');
+      el.className = 'rect r-' + (rc.tag || 'ghost');
+      const left = offX + rc.x * r.width;
+      const top  = offY + rc.y * r.height;
+      el.style.left   = left + 'px';
+      el.style.top    = top  + 'px';
+      el.style.width  = (rc.w * r.width) + 'px';
+      el.style.height = (rc.h * r.height) + 'px';
+      el.innerHTML = `<div class="label">${(rc.tag || 'ghost').toUpperCase()} #${i+1}</div><div class="x" data-i="${i}" title="delete (right-click also works)">×</div>`;
+      el.oncontextmenu = (ev) => { ev.preventDefault(); RECTS.splice(i,1); drawRects(); updateRectCount(); };
+      el.querySelector('.x').onclick = (ev) => { ev.stopPropagation(); RECTS.splice(i,1); drawRects(); updateRectCount(); };
+      imgWrap.appendChild(el);
+    });
+  }
+  // Keep rects in sync if window resizes
+  window.addEventListener('resize', () => { if (CURRENT !== null) drawRects(); });
+
+  function updateRectCount() {
+    const n = RECTS.length;
+    const gn = RECTS.filter(r => r.tag === 'ghost').length;
+    const sn = RECTS.filter(r => r.tag === 'seam').length;
+    const on = RECTS.filter(r => r.tag === 'other').length;
+    $('rect-count').innerHTML = n
+      ? `<b class="has">${n}</b> region${n>1?'s':''} marked  · ${gn} ghost / ${sn} seam / ${on} other`
+      : 'no regions marked yet — drag on the image to mark defect zones';
+    $('bn-ghost').textContent = gn;
+    $('bn-seam').textContent  = sn;
+    $('bn-other').textContent = on;
+    $('btn-ghost').classList.toggle('gated', gn === 0);
+    $('btn-seam').classList.toggle('gated',  sn === 0);
+    $('btn-other').classList.toggle('gated', on === 0);
+  }
+
+  $('undo-btn').onclick = () => { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } };
+  $('clear-btn').onclick = () => { RECTS = []; drawRects(); updateRectCount(); };
 
-  function showCropReadout() {
-    if (!PENDING_CROP) return;
-    const c = PENDING_CROP;
-    $('crop-readout').style.display = '';
-    $('crop-readout').textContent =
-      `crop [x=${c.x.toFixed(3)} y=${c.y.toFixed(3)} w=${c.w.toFixed(3)} h=${c.h.toFixed(3)}]  — press T again to save`;
-    $('clear-crop').style.display = '';
-  }
-  function clearCrop() {
-    PENDING_CROP = null;
-    ARMED_TP = false;
-    cropbox.style.display = 'none';
-    $('hint').style.display = 'none';
-    $('crop-readout').style.display = 'none';
-    $('clear-crop').style.display = 'none';
-    $('btn-tp').classList.remove('armed');
-  }
-  $('clear-crop').onclick = clearCrop;
-
-  // --- Verdict actions ---------------------------------------------------
-  async function postLabel(verdict, crop) {
+  // ── Seam test (quadrant-swap to bring outer edges to center) ───────────
+  function buildSeamCanvas() {
+    const w = mainImg.naturalWidth, h = mainImg.naturalHeight;
+    if (!w || !h) return;
+    seamCanvas.width = w;
+    seamCanvas.height = h;
+    // Match displayed size of the original img
+    const ir = mainImg.getBoundingClientRect();
+    if (ir.width > 0) {
+      seamCanvas.style.width  = ir.width + 'px';
+      seamCanvas.style.height = ir.height + 'px';
+    }
+    const ctx = seamCanvas.getContext('2d');
+    const hw = Math.floor(w / 2), hh = Math.floor(h / 2);
+    // Quadrant-swap (classic Photoshop Offset filter w/ wrap):
+    //   new TL ← old BR | new TR ← old BL
+    //   new BL ← old TR | new BR ← old TL
+    // Result: the previously-OUTER edges of the source (top/bottom/left/right)
+    // all meet at the new CENTER cross. A truly tileable pattern shows no
+    // seam at the cross; a broken tile shouts here.
+    ctx.drawImage(mainImg, hw, hh, w-hw, h-hh, 0,    0,    w-hw, h-hh);  // BR → TL
+    ctx.drawImage(mainImg, 0,  hh, hw,   h-hh, w-hw, 0,    hw,   h-hh);  // BL → TR
+    ctx.drawImage(mainImg, hw, 0,  w-hw, hh,   0,    h-hh, w-hw, hh);    // TR → BL
+    ctx.drawImage(mainImg, 0,  0,  hw,   hh,   w-hw, h-hh, hw,   hh);    // TL → BR
+  }
+  function toggleSeamView() {
+    if (CURRENT === null) return;
+    SEAM_VIEW = !SEAM_VIEW;
+    const btn = $('seam-test-btn');
+    if (SEAM_VIEW) {
+      buildSeamCanvas();
+      mainImg.style.visibility = 'hidden';
+      mainImg.style.position = 'absolute';   // collapse layout so wrap fits canvas
+      seamCanvas.style.display = '';
+      btn.classList.add('active');
+      btn.innerHTML = '<span class="icon">⊟</span> Exit 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 = 'Seam-test view — outer edges now meet at the dashed center cross. Drag any visible seam to mark it as <b>seam-break</b> (B)';
+    } else {
+      mainImg.style.visibility = '';
+      mainImg.style.position = '';
+      seamCanvas.style.display = 'none';
+      btn.classList.remove('active');
+      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);
+  }
+  $('seam-test-btn').onclick = toggleSeamView;
+
+  // ── Save verdict ───────────────────────────────────────────────────────
+  function toast(msg) {
+    const t = $('toast');
+    t.textContent = msg;
+    t.classList.add('show');
+    clearTimeout(t._tm);
+    t._tm = setTimeout(() => t.classList.remove('show'), 1400);
+  }
+  async function save(verdict, opts) {
     if (CURRENT === null) return;
     const item = VIEW[CURRENT];
-    const body = { id: item.id, verdict, crop: crop || null };
+    let crops = [];
+    if (verdict === 'tp_ghost') crops = RECTS.filter(r => r.tag === 'ghost');
+    else if (verdict === 'tp_seam') crops = RECTS.filter(r => r.tag === 'seam');
+    else if (verdict === 'tp_other') crops = RECTS.filter(r => r.tag === 'other');
+    if (verdict.startsWith('tp') && !crops.length) { toast('draw at least one ' + verdict.split('_')[1] + ' region first'); return; }
+    const body = { id: item.id, verdict, crops, notes: $('notes').value || null };
     try {
       const r = await fetch('/api/ghost-review/label', {
         method:'POST', headers:{'Content-Type':'application/json'},
-        body: JSON.stringify(body)
+        body: JSON.stringify(body),
       });
-      if (!r.ok) throw new Error('http ' + r.status);
-      LABELS.set(item.id, { verdict, crop: crop || null });
-      STAT[verdict === 'skip' ? 'skip' : verdict]++;
+      if (!r.ok) { const t = await r.text(); throw new Error(t.slice(0,200)); }
+      const prev = LABELS.get(item.id);
+      // Update stats — decrement old verdict if any, increment new
+      if (prev) {
+        if (prev.verdict === 'fp') STAT.fp--;
+        else if (prev.verdict === 'tp_ghost') STAT.ghost--;
+        else if (prev.verdict === 'tp_seam')  STAT.seam--;
+        else if (prev.verdict === 'tp_other') STAT.other--;
+        else if (prev.verdict === 'skip') STAT.skip--;
+      }
+      if (verdict === 'fp') STAT.fp++;
+      else if (verdict === 'tp_ghost') STAT.ghost++;
+      else if (verdict === 'tp_seam')  STAT.seam++;
+      else if (verdict === 'tp_other') STAT.other++;
+      else if (verdict === 'skip') STAT.skip++;
+      LABELS.set(item.id, { verdict, crops });
       updateStats();
-      const goingForward = (CURRENT < VIEW.length - 1);
-      // If on Unlabeled filter, the item leaves the view — index stays valid
+      toast({ fp:'✓ saved CLEAN', tp_ghost:'⚠ saved GHOST', tp_seam:'⤬ saved SEAM', tp_other:'◆ saved OTHER', skip:'– skipped' }[verdict]);
+      // Auto-advance
       if (FILTER === 'unlabeled') {
-        recomputeView();
-        render();
+        recomputeView(); render();
         if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
         if (VIEW.length === 0) { close(); return; }
         open(CURRENT);
       } else {
-        if (goingForward) navigate(1);
         recomputeView(); render();
+        if (CURRENT < VIEW.length - 1) navigate(1);
       }
     } catch (e) {
-      alert('Save failed: ' + e.message);
+      toast('save failed: ' + e.message);
     }
   }
+  $('btn-fp').onclick    = () => save('fp');
+  $('btn-ghost').onclick = () => save('tp_ghost');
+  $('btn-seam').onclick  = () => save('tp_seam');
+  $('btn-other').onclick = () => save('tp_other');
+  $('btn-skip').onclick  = () => save('skip');
 
-  $('btn-fp').onclick = () => postLabel('fp');
-  $('btn-skip').onclick = () => postLabel('skip');
-  $('btn-tp').onclick = () => {
-    if (PENDING_CROP) { postLabel('tp', PENDING_CROP); return; }
-    if (!ARMED_TP) {
-      ARMED_TP = true;
-      $('hint').style.display = '';
-      $('btn-tp').classList.add('armed');
-      stage.style.cursor = 'crosshair';
-    }
-  };
-
-  // Keyboard
+  // ── Keyboard ───────────────────────────────────────────────────────────
   document.addEventListener('keydown', (e) => {
     if (!modal.classList.contains('open')) return;
-    if (e.key === 'Escape') { close(); }
-    else if (e.key === 'ArrowLeft') { navigate(-1); }
-    else if (e.key === 'ArrowRight') { navigate(1); }
-    else if (e.key.toLowerCase() === 'f') { postLabel('fp'); }
-    else if (e.key.toLowerCase() === 's') { postLabel('skip'); }
-    else if (e.key.toLowerCase() === 't') {
-      if (PENDING_CROP) postLabel('tp', PENDING_CROP);
-      else $('btn-tp').click();
-    }
+    if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
+    const k = e.key.toLowerCase();
+    if (k === 'escape') close();
+    else if (k === 'arrowleft')  navigate(-1);
+    else if (k === 'arrowright') navigate(1);
+    else if (k === 'f') save('fp');
+    else if (k === 'g') save('tp_ghost');
+    else if (k === 'b') save('tp_seam');
+    else if (k === 'o') save('tp_other');
+    else if (k === 's') save('skip');
+    else if (k === 'u') { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } }
+    else if (k === '1') { DRAW_MODE = 'ghost'; updateModeButtons(); }
+    else if (k === '2') { DRAW_MODE = 'seam';  updateModeButtons(); }
+    else if (k === '3') { DRAW_MODE = 'other'; updateModeButtons(); }
+    else if (k === 'x') { toggleSeamView(); }
   });
 
-  // --- Boot --------------------------------------------------------------
+  // ── Boot ───────────────────────────────────────────────────────────────
   async function boot() {
     const [flagged, labels] = await Promise.all([
       fetch('/api/ghost-review/flagged').then(r => r.json()),
@@ -531,12 +1014,14 @@
     ALL = flagged.items;
     STAT.total = ALL.length;
     for (const lbl of labels.items) {
-      LABELS.set(lbl.id, { verdict: lbl.verdict, crop: lbl.crop });
-      if (lbl.verdict === 'fp') STAT.fp++;
-      else if (lbl.verdict === 'tp') STAT.tp++;
-      else if (lbl.verdict === 'skip') STAT.skip++;
+      LABELS.set(lbl.id, { verdict: lbl.verdict, crops: lbl.crops || (lbl.crop ? [lbl.crop] : []), notes: lbl.notes });
+      if (lbl.verdict === 'fp')        STAT.fp++;
+      else if (lbl.verdict === 'tp_ghost') STAT.ghost++;
+      else if (lbl.verdict === 'tp_seam')  STAT.seam++;
+      else if (lbl.verdict === 'tp_other') STAT.other++;
+      else if (lbl.verdict === 'tp')        STAT.ghost++;  // legacy
+      else if (lbl.verdict === 'skip')      STAT.skip++;
     }
-    // Populate category filter
     const cats = [...new Set(ALL.map(i => i.category).filter(Boolean))].sort();
     const sel = $('filter-cat');
     for (const c of cats) {
diff --git a/server.js b/server.js
index dee84f2..0d40fd7 100644
--- a/server.js
+++ b/server.js
@@ -951,15 +951,36 @@ app.get('/api/ghost-review/labels', (req, res) => {
   res.json({ ok: true, items: [...latest.values()] });
 });
 
-app.post('/api/ghost-review/label', express.json({ limit: '8kb' }), (req, res) => {
+// Verdict taxonomy:
+//   fp        — false positive (image is clean, detector was wrong)
+//   tp_ghost  — true positive, ghost-layer defect (translucent overlay)
+//   tp_seam   — true positive, poor seam/tile-overlap defect
+//   tp_other  — true positive, other defect (free-form notes field)
+//   skip      — defer decision
+// crops[] is per-region marks, tagged with the same verdict for now.
+const VALID_VERDICTS = new Set(['fp','tp_ghost','tp_seam','tp_other','skip','tp']);
+app.post('/api/ghost-review/label', express.json({ limit: '64kb' }), (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
-  const { id, verdict, crop } = req.body || {};
-  if (!Number.isFinite(id))                       return res.status(400).json({ error: 'id required' });
-  if (!['fp','tp','skip'].includes(verdict))      return res.status(400).json({ error: 'verdict must be fp|tp|skip' });
-  if (verdict === 'tp' && (!crop || typeof crop.x !== 'number'))
-                                                   return res.status(400).json({ error: 'tp requires crop {x,y,w,h}' });
+  let { id, verdict, crops, crop, notes } = req.body || {};
+  if (!Number.isFinite(id))               return res.status(400).json({ error: 'id required' });
+  if (!VALID_VERDICTS.has(verdict))       return res.status(400).json({ error: 'verdict must be fp|tp_ghost|tp_seam|tp_other|skip' });
+  if (!Array.isArray(crops)) crops = crop ? [crop] : [];
+  const isTrue = verdict.startsWith('tp');
+  if (isTrue && !crops.length)
+    return res.status(400).json({ error: 'true-positive requires at least one crop {x,y,w,h}' });
+  for (const c of crops) {
+    if (typeof c?.x !== 'number' || typeof c?.y !== 'number'
+        || typeof c?.w !== 'number' || typeof c?.h !== 'number') {
+      return res.status(400).json({ error: 'crop must be {x,y,w,h} numbers' });
+    }
+  }
   const f = path.join(__dirname, 'data', 'ghost-labels.jsonl');
-  const row = { id, verdict, crop: crop || null, ts: new Date().toISOString() };
+  const row = {
+    id, verdict, crops,
+    crop: crops[0] || null,           // back-compat
+    notes: notes ? String(notes).slice(0, 500) : null,
+    ts: new Date().toISOString(),
+  };
   try {
     fs.appendFileSync(f, JSON.stringify(row) + '\n');
     res.json({ ok: true });

← 788de56 ghost-detector: 429 retry-with-backoff + triage-ghost-flagge  ·  back to Wallco Ai  ·  ghost-review: auto-suggest dotted regions + tolerance slider 7adcd3c →