[object Object]

← back to Wallco Ai

seam-defect localizer + /admin/seam-debug viewer (per-row/per-col ΔE → boxes) · rooms sort by pattern · debug-seams link per card

a55e2ac786004981b671dd1d30007d7f647ab2f2 · 2026-05-28 05:38:44 -0700 · Steve Abrams

Files touched

Diff

commit a55e2ac786004981b671dd1d30007d7f647ab2f2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 05:38:44 2026 -0700

    seam-defect localizer + /admin/seam-debug viewer (per-row/per-col ΔE → boxes) · rooms sort by pattern · debug-seams link per card
---
 data/auto-decide-ledger.jsonl    | 120 +++++++++++++++++++
 data/cactus-decisions.jsonl      |   7 ++
 public/admin/cactus-curator.html |   5 +
 public/admin/rooms.html          |  19 ++-
 public/admin/seam-debug.html     | 143 +++++++++++++++++++++++
 scripts/seam-defect-boxes.py     | 245 +++++++++++++++++++++++++++++++++++++++
 server.js                        |  31 +++++
 7 files changed, 568 insertions(+), 2 deletions(-)

diff --git a/data/auto-decide-ledger.jsonl b/data/auto-decide-ledger.jsonl
index ba9532b..2d5eadb 100644
--- a/data/auto-decide-ledger.jsonl
+++ b/data/auto-decide-ledger.jsonl
@@ -4032,3 +4032,123 @@
 {"t":"2026-05-27T23:58:28.553Z","id":53792,"verdict":"not","reason":"large-empty-void","see":"There are three instances of a bird and floral motif with a large empty area."}
 {"t":"2026-05-27T23:58:30.595Z","id":53813,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of animal motifs."}
 {"t":"2026-05-27T23:58:32.942Z","id":53814,"verdict":"hot_candidate","n":12,"see":"A repeating pattern of various vases and pots."}
+{"t":"2026-05-28T12:33:54.483Z","id":53816,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"Two martini glasses are visible with a large empty area."}
+{"t":"2026-05-28T12:33:57.117Z","id":53817,"verdict":"hot_candidate","n":12,"see":"This is a wallpaper tile with a repeating pattern of various motifs."}
+{"t":"2026-05-28T12:33:59.586Z","id":53818,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating pattern of raccoons and other motifs."}
+{"t":"2026-05-28T12:34:02.130Z","id":53819,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of glasses and a skull jar."}
+{"t":"2026-05-28T12:34:04.321Z","id":53820,"verdict":"not","reason":"centered-hero (n=1)","see":"A single large motif is centered on the tile."}
+{"t":"2026-05-28T12:34:06.779Z","id":53822,"verdict":"hot_candidate","n":5,"see":"I see a wallpaper repeat with a floral motif."}
+{"t":"2026-05-28T12:34:09.409Z","id":53824,"verdict":"hot_candidate","n":8,"see":"There are multiple instances of the same motif spread across the tile."}
+{"t":"2026-05-28T12:34:11.787Z","id":53826,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see a tile with two large motifs and a large empty area."}
+{"t":"2026-05-28T12:34:14.393Z","id":53827,"verdict":"not","reason":"centered-hero (n=2)","see":"A centered cocktail glass with partial motifs around it."}
+{"t":"2026-05-28T12:34:16.825Z","id":53829,"verdict":"hot_candidate","n":6,"see":"A repeating pattern of wine bottles, glasses, and flowers."}
+{"t":"2026-05-28T12:34:19.160Z","id":53831,"verdict":"not","reason":"large-empty-void","see":"A repeating pattern of martini glasses with a large empty area."}
+{"t":"2026-05-28T12:34:21.798Z","id":53832,"verdict":"hot_candidate","n":6,"see":"A wallpaper tile with a repeating pattern of cartoonish figures."}
+{"t":"2026-05-28T12:34:24.134Z","id":53834,"verdict":"not","reason":"too-few-instances (n=0<3)","see":"It's a solid tan color."}
+{"t":"2026-05-28T12:34:26.638Z","id":53835,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of cocktail glasses with fruit."}
+{"t":"2026-05-28T12:34:29.052Z","id":53837,"verdict":"hot_candidate","n":7,"see":"A repeating wallpaper pattern with raccoons, bottles, and other motifs."}
+{"t":"2026-05-28T12:34:31.378Z","id":53839,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of skulls and striped shapes."}
+{"t":"2026-05-28T12:34:33.779Z","id":53840,"verdict":"not","reason":"centered-hero (n=1)","see":"A single large motif is centered with a large empty area."}
+{"t":"2026-05-28T12:34:36.219Z","id":53842,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see two instances of a cocktail glass with an owl."}
+{"t":"2026-05-28T12:34:38.884Z","id":53843,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see a wallpaper tile with a large empty area and two instances of the dominant motif."}
+{"t":"2026-05-28T12:34:41.430Z","id":53845,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"There are two instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:34:44.170Z","id":53846,"verdict":"not","reason":"large-empty-void","see":"This tile features a sloth, a bottle, and a wine glass."}
+{"t":"2026-05-28T12:34:46.657Z","id":53847,"verdict":"not","reason":"centered-hero (n=1)","see":"A single wine glass is centered on a large empty background."}
+{"t":"2026-05-28T12:34:49.060Z","id":53849,"verdict":"not","reason":"large-empty-void","see":"I see three instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:34:51.351Z","id":53851,"verdict":"hot_candidate","n":3,"see":"I see a wallpaper tile with a repeating motif of cartoonish figures and a bottle."}
+{"t":"2026-05-28T12:34:53.928Z","id":53852,"verdict":"hot_candidate","n":7,"see":"A repeating pattern of monkeys and leaves."}
+{"t":"2026-05-28T12:34:56.114Z","id":53853,"verdict":"hot_candidate","n":5,"see":"A wallpaper tile with a repeating pattern of stylized objects."}
+{"t":"2026-05-28T12:34:58.562Z","id":53855,"verdict":"hot_candidate","n":5,"see":"A wallpaper tile with a repeating pattern of food and drink motifs."}
+{"t":"2026-05-28T12:35:00.836Z","id":53857,"verdict":"hot_candidate","n":4,"see":"There are multiple instances of cocktail motifs."}
+{"t":"2026-05-28T12:35:03.221Z","id":53858,"verdict":"not","reason":"centered-hero (n=2)","see":"A centered hero motif of vases."}
+{"t":"2026-05-28T12:35:05.886Z","id":53859,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating pattern of monkeys, bottles, and plants."}
+{"t":"2026-05-28T12:35:08.146Z","id":53860,"verdict":"not","reason":"large-empty-void","see":"I see three instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:35:10.531Z","id":53861,"verdict":"hot_candidate","n":3,"see":"This is a wallpaper tile with a repeating giraffe motif."}
+{"t":"2026-05-28T12:35:12.829Z","id":53862,"verdict":"not","reason":"large-empty-void","see":"I see a wallpaper tile with a large empty area and a few instances of a floral motif."}
+{"t":"2026-05-28T12:35:15.493Z","id":53863,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of dessert glasses."}
+{"t":"2026-05-28T12:35:18.026Z","id":53864,"verdict":"not","reason":"centered-hero (n=2)","see":"A large wine glass with a bird in the center."}
+{"t":"2026-05-28T12:35:20.241Z","id":53865,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of stylized faces and foliage."}
+{"t":"2026-05-28T12:35:22.860Z","id":53866,"verdict":"hot_candidate","n":6,"see":"A repeating pattern of bottles and vases."}
+{"t":"2026-05-28T12:35:25.243Z","id":53867,"verdict":"hot_candidate","n":3,"see":"A wallpaper tile with a repeating pattern of monkeys, flowers, and leaves."}
+{"t":"2026-05-28T12:35:27.555Z","id":53868,"verdict":"hot_candidate","n":6,"see":"A wallpaper tile with a repeating pattern of bottles and flowers."}
+{"t":"2026-05-28T12:35:29.907Z","id":53869,"verdict":"hot_candidate","n":5,"see":"A wallpaper tile with a repeating floral motif."}
+{"t":"2026-05-28T12:35:32.691Z","id":53870,"verdict":"not","reason":"large-empty-void","see":"There are three instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:35:35.023Z","id":53871,"verdict":"hot_candidate","n":8,"see":"A repeating wallpaper tile with a monkey motif."}
+{"t":"2026-05-28T12:35:37.154Z","id":53872,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of frogs in pots."}
+{"t":"2026-05-28T12:35:39.524Z","id":53873,"verdict":"not","reason":"centered-hero (n=1)","see":"A single large motif is centered with a large empty area."}
+{"t":"2026-05-28T12:35:41.863Z","id":53874,"verdict":"not","reason":"centered-hero (n=2)","see":"There are two frogs, one is centered."}
+{"t":"2026-05-28T12:35:44.062Z","id":53875,"verdict":"hot_candidate","n":10,"see":"A repeating pattern of abstract shapes and figures."}
+{"t":"2026-05-28T12:35:46.550Z","id":53876,"verdict":"not","reason":"large-empty-void","see":"I see a repeating pattern with a large empty area."}
+{"t":"2026-05-28T12:35:48.821Z","id":53877,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see two instances of a figure with a bird in a frame on an orange background."}
+{"t":"2026-05-28T12:35:51.417Z","id":53878,"verdict":"hot_candidate","n":5,"see":"I see a repeating pattern of stylized leaves and floral elements."}
+{"t":"2026-05-28T12:35:53.812Z","id":53879,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"There are two partial floral motifs with a large empty area."}
+{"t":"2026-05-28T12:35:56.077Z","id":53880,"verdict":"not","reason":"centered-hero (n=3)","see":"A centered motif of a glass with a squirrel."}
+{"t":"2026-05-28T12:35:58.598Z","id":53881,"verdict":"hot_candidate","n":8,"see":"This is a wallpaper repeat of stylized vases and swords."}
+{"t":"2026-05-28T12:36:01.087Z","id":53882,"verdict":"hot_candidate","n":9,"see":"A repeating pattern of antique vases and goblets."}
+{"t":"2026-05-28T12:36:03.376Z","id":53883,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see two instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:36:05.803Z","id":53884,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"There are two instances of a fountain motif with a large empty area."}
+{"t":"2026-05-28T12:36:08.437Z","id":53885,"verdict":"hot_candidate","n":5,"see":"This is a wallpaper repeat with multiple instances of the same motif."}
+{"t":"2026-05-28T12:36:10.780Z","id":53886,"verdict":"not","reason":"centered-hero (n=1)","see":"A single large motif is centered on the tile."}
+{"t":"2026-05-28T12:36:13.312Z","id":53887,"verdict":"hot_candidate","n":6,"see":"A wallpaper tile with a repeating pattern of bottles."}
+{"t":"2026-05-28T12:36:15.958Z","id":53888,"verdict":"hot_candidate","n":6,"see":"A repeating pattern of bottles and vases."}
+{"t":"2026-05-28T12:36:18.432Z","id":53889,"verdict":"not","reason":"centered-hero (n=1)","see":"A single large motif dominates the tile."}
+{"t":"2026-05-28T12:36:20.913Z","id":53890,"verdict":"hot_candidate","n":6,"see":"The image shows a wallpaper tile with a repeating pattern of vases and other motifs."}
+{"t":"2026-05-28T12:36:23.188Z","id":53891,"verdict":"hot_candidate","n":6,"see":"Repeating pattern of elephant-themed objects."}
+{"t":"2026-05-28T12:36:25.626Z","id":53892,"verdict":"hot_candidate","n":6,"see":"The tile shows a repeating pattern of whimsical teacup creatures."}
+{"t":"2026-05-28T12:36:27.773Z","id":53893,"verdict":"hot_candidate","n":15,"see":"A repeating pattern of orange and blue abstract shapes."}
+{"t":"2026-05-28T12:36:30.098Z","id":53894,"verdict":"not","reason":"centered-hero (n=3)","see":"A large motif is centered with partial motifs around it."}
+{"t":"2026-05-28T12:36:32.609Z","id":53895,"verdict":"hot_candidate","n":12,"see":"A repeating pattern of various vessels and objects."}
+{"t":"2026-05-28T12:36:35.293Z","id":53896,"verdict":"hot_candidate","n":6,"see":"The image shows a wallpaper tile with a repeating pattern of vases and teapots on shelves."}
+{"t":"2026-05-28T12:36:37.708Z","id":53897,"verdict":"hot_candidate","n":10,"see":"This is a wallpaper repeat with multiple instances of a red and blue motif."}
+{"t":"2026-05-28T12:36:40.175Z","id":53898,"verdict":"hot_candidate","n":3,"see":"I see a wallpaper tile with a repeating motif of a glass vessel."}
+{"t":"2026-05-28T12:36:42.640Z","id":53899,"verdict":"hot_candidate","n":4,"see":"I see a repeating pattern of surreal creatures and objects."}
+{"t":"2026-05-28T12:36:45.143Z","id":53900,"verdict":"hot_candidate","n":5,"see":"I see a repeating pattern of purple floral motifs on a light yellow background."}
+{"t":"2026-05-28T12:36:47.530Z","id":53901,"verdict":"hot_candidate","n":7,"see":"The image shows a wallpaper tile with a repeating pattern of tribal masks."}
+{"t":"2026-05-28T12:36:50.261Z","id":53902,"verdict":"hot_candidate","n":10,"see":"A wallpaper tile with a repeating pattern of objects on stands."}
+{"t":"2026-05-28T12:36:52.710Z","id":53903,"verdict":"hot_candidate","n":7,"see":"I see a repeating pattern of stylized glasses."}
+{"t":"2026-05-28T12:36:55.313Z","id":53904,"verdict":"not","reason":"large-empty-void","see":"I see a tile with four bottle motifs and a large empty area."}
+{"t":"2026-05-28T12:36:57.783Z","id":53905,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see a tile with a large empty area and two instances of the dominant motif."}
+{"t":"2026-05-28T12:37:00.254Z","id":53906,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating motif of vases and flowers."}
+{"t":"2026-05-28T12:37:02.890Z","id":53907,"verdict":"hot_candidate","n":3,"see":"I see a wallpaper tile with monkeys and plants."}
+{"t":"2026-05-28T12:37:05.479Z","id":53908,"verdict":"hot_candidate","n":7,"see":"A wallpaper tile with a repeating motif of hanging objects."}
+{"t":"2026-05-28T12:37:07.978Z","id":53909,"verdict":"hot_candidate","n":10,"see":"I see a wallpaper repeat of bottles and birds on shelves."}
+{"t":"2026-05-28T12:37:10.328Z","id":53910,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"There are two partial motifs on the sides with a large empty area in the middle."}
+{"t":"2026-05-28T12:37:12.702Z","id":53911,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see two instances of a cocktail glass motif with a large empty area."}
+{"t":"2026-05-28T12:37:14.924Z","id":53912,"verdict":"hot_candidate","n":10,"see":"A repeating pattern of various bottles and containers."}
+{"t":"2026-05-28T12:37:16.974Z","id":53913,"verdict":"not","reason":"large-empty-void","see":"I see a wallpaper tile with a repeating floral motif and a large empty area."}
+{"t":"2026-05-28T12:37:19.263Z","id":53914,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of cocktail glasses and garnishes."}
+{"t":"2026-05-28T12:37:21.569Z","id":53915,"verdict":"hot_candidate","n":7,"see":"This is a wallpaper repeat with a floral motif."}
+{"t":"2026-05-28T12:37:24.115Z","id":53916,"verdict":"hot_candidate","n":10,"see":"A repeating pattern of abstract shapes and eye motifs."}
+{"t":"2026-05-28T12:37:26.589Z","id":53917,"verdict":"not","reason":"large-empty-void","see":"There are three instances of the motif with a large empty area."}
+{"t":"2026-05-28T12:37:28.853Z","id":53918,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of leaves and butterflies."}
+{"t":"2026-05-28T12:37:31.150Z","id":53919,"verdict":"not","reason":"large-empty-void","see":"A wallpaper tile with a teapot motif and large empty area."}
+{"t":"2026-05-28T12:37:33.649Z","id":53920,"verdict":"hot_candidate","n":12,"see":"A wallpaper tile with a repeating pattern of various vases and glasses."}
+{"t":"2026-05-28T12:37:35.799Z","id":53921,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of camel silhouettes with cocktails."}
+{"t":"2026-05-28T12:37:38.397Z","id":53922,"verdict":"hot_candidate","n":5,"see":"A repeating pattern of illustrated glasses and other objects."}
+{"t":"2026-05-28T12:37:40.689Z","id":53923,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"There are two partial motifs with a large empty area."}
+{"t":"2026-05-28T12:37:42.815Z","id":53924,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see a large empty area with two partial motifs."}
+{"t":"2026-05-28T12:37:44.862Z","id":53925,"verdict":"not","reason":"too-few-instances (n=0<3)","see":"It's a solid gray background."}
+{"t":"2026-05-28T12:37:47.689Z","id":53926,"verdict":"hot_candidate","n":4,"see":"This is a wallpaper tile with a repeating elephant motif."}
+{"t":"2026-05-28T12:37:49.739Z","id":53927,"verdict":"hot_candidate","n":4,"see":"The tile shows a repeating pattern of a tapir with fruit."}
+{"t":"2026-05-28T12:37:52.420Z","id":53928,"verdict":"not","reason":"centered-hero (n=2)","see":"A large motif is centered with smaller motifs around it."}
+{"t":"2026-05-28T12:37:54.608Z","id":53929,"verdict":"hot_candidate","n":5,"see":"The image shows a wallpaper tile with a repeating pattern of various bottles and containers."}
+{"t":"2026-05-28T12:37:56.791Z","id":53930,"verdict":"hot_candidate","n":8,"see":"A repeating pattern of frogs in glasses and on plates."}
+{"t":"2026-05-28T12:37:59.147Z","id":53931,"verdict":"hot_candidate","n":5,"see":"A wallpaper tile with a repeating monkey and fruit tree motif."}
+{"t":"2026-05-28T12:38:01.384Z","id":53932,"verdict":"hot_candidate","n":4,"see":"A repeating pattern of teapots and figures."}
+{"t":"2026-05-28T12:38:03.736Z","id":53933,"verdict":"hot_candidate","n":12,"see":"A wallpaper tile with a repeating pattern of various objects."}
+{"t":"2026-05-28T12:38:06.069Z","id":53934,"verdict":"hot_candidate","n":14,"see":"A wallpaper tile with a repeating pattern of various vases and vessels."}
+{"t":"2026-05-28T12:38:08.860Z","id":53935,"verdict":"not","reason":"centered-hero (n=1)","see":"A single perfume bottle is centered among monstera leaves."}
+{"t":"2026-05-28T12:38:11.154Z","id":53936,"verdict":"hot_candidate","n":10,"see":"A repeating pattern of abstract shapes in blue, pink, and orange."}
+{"t":"2026-05-28T12:38:13.625Z","id":53937,"verdict":"hot_candidate","n":4,"see":"The image shows a wallpaper tile with a repeating pattern of jars and branches."}
+{"t":"2026-05-28T12:38:15.955Z","id":53938,"verdict":"not","reason":"centered-hero (n=4)","see":"A centered hero tile with a large empty area."}
+{"t":"2026-05-28T12:38:18.267Z","id":53939,"verdict":"not","reason":"too-few-instances (n=2<3)","see":"I see two partial elephant head motifs with a large empty area."}
+{"t":"2026-05-28T12:38:20.641Z","id":53940,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating pattern of pots, bottles, and otters."}
+{"t":"2026-05-28T12:38:23.204Z","id":53941,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating pattern of stylized cats and plants."}
+{"t":"2026-05-28T12:38:25.868Z","id":53942,"verdict":"not","reason":"large-empty-void","see":"I see a wallpaper tile with a large empty area and three instances of the dominant motif."}
+{"t":"2026-05-28T12:38:28.303Z","id":53943,"verdict":"hot_candidate","n":3,"see":"This is a wallpaper tile with a repeating pattern of plants and objects on shelves."}
+{"t":"2026-05-28T12:38:30.652Z","id":53944,"verdict":"not","reason":"centered-hero (n=1)","see":"A large motif is centered with a large empty area."}
+{"t":"2026-05-28T12:38:33.138Z","id":53945,"verdict":"hot_candidate","n":6,"see":"The tile shows a repeating pattern of cocktail glasses and other barware."}
+{"t":"2026-05-28T12:38:35.814Z","id":53946,"verdict":"hot_candidate","n":4,"see":"A wallpaper tile with a repeating pattern of ornate objects."}
+{"t":"2026-05-28T12:38:38.223Z","id":53947,"verdict":"not","reason":"centered-hero (n=5)","see":"A centered vase with flowers and birds."}
+{"t":"2026-05-28T12:38:40.752Z","id":53948,"verdict":"hot_candidate","n":6,"see":"A wallpaper tile with a repeating pattern of stylized glasses and flowers."}
+{"t":"2026-05-28T12:38:43.792Z","id":53949,"verdict":"hot_candidate","n":12,"see":"A wallpaper tile featuring a repeating pattern of vases and bottles on a shelf."}
diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index f8412a7..2c83f10 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3037,3 +3037,10 @@
 {"ts":"2026-05-27T17:58:08.628Z","id":39138,"action":"bad"}
 {"ts":"2026-05-27T18:18:18.733Z","id":17123,"action":"fix"}
 {"ts":"2026-05-27T18:25:14.954Z","id":41607,"action":"fix"}
+{"ts":"2026-05-28T12:22:51.872Z","id":39347,"action":"live"}
+{"ts":"2026-05-28T12:23:16.380Z","id":34826,"action":"fix"}
+{"ts":"2026-05-28T12:23:23.200Z","id":35610,"action":"bad"}
+{"ts":"2026-05-28T12:24:11.253Z","id":30158,"action":"fix"}
+{"ts":"2026-05-28T12:24:15.105Z","id":29629,"action":"bad"}
+{"ts":"2026-05-28T12:24:18.623Z","id":29556,"action":"bad"}
+{"ts":"2026-05-28T12:24:21.753Z","id":35419,"action":"bad"}
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 6a75637..52551e2 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -77,6 +77,10 @@
                  border:1px solid #6a5a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px; cursor:pointer; }
   .auto-id-btn:hover { background:#ffb300; color:#1a1a1a; }
   .auto-id-btn.busy { opacity:.5; cursor:wait; }
+  .dbg-btn { position:absolute; bottom:6px; right:6px; z-index:4; background:rgba(0,0,0,.7); color:#ff9a9a;
+             border:1px solid #6a3a3a; border-radius:5px; font:700 10px sans-serif; padding:3px 8px;
+             text-decoration:none; cursor:pointer; }
+  .dbg-btn:hover { background:#ff4d4d; color:#fff; border-color:#ff4d4d; }
   /* marquee rubber-band selection */
   #marquee { position:fixed; z-index:40; border:1.5px solid #4aa3ff; background:rgba(74,163,255,.16); pointer-events:none; display:none; border-radius:3px; }
   body.dragging { user-select:none; cursor:crosshair; }
@@ -323,6 +327,7 @@ function card(d){
       <img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
       ${seamCallout}
       <button class="auto-id-btn" title="auto-detect bad areas (you confirm/delete)">🔍 Auto-ID</button>
+      <a class="dbg-btn" href="${q('/admin/seam-debug/'+d.id)}" target="_blank" title="open seam-defect debugger — see where the mid-line breaks">🔬 Debug seams</a>
       <div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
     </div>
     <div class="meta">
diff --git a/public/admin/rooms.html b/public/admin/rooms.html
index 9b0f04c..ab5b8ea 100644
--- a/public/admin/rooms.html
+++ b/public/admin/rooms.html
@@ -31,6 +31,14 @@
   <label class="ctl">Room
     <select id="filter"><option value="">all types</option></select>
   </label>
+  <label class="ctl">Sort
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="pattern">Pattern (group by design)</option>
+      <option value="oldest">Oldest</option>
+      <option value="rtype">Room type</option>
+    </select>
+  </label>
   <label class="ctl">Density <input type="range" id="density" min="2" max="8" step="1" value="4"></label>
 </header>
 <div id="err" style="display:none"></div>
@@ -44,10 +52,17 @@ let ALL=[];
 $('density').value=+(localStorage.getItem('rooms_density')||4); document.documentElement.style.setProperty('--cols',$('density').value);
 $('density').addEventListener('input',e=>{localStorage.setItem('rooms_density',e.target.value);document.documentElement.style.setProperty('--cols',e.target.value);});
 $('filter').addEventListener('change',render);
+$('sort').value=localStorage.getItem('rooms_sort')||'newest';
+$('sort').addEventListener('change',e=>{localStorage.setItem('rooms_sort',e.target.value);render();});
 function fmt(iso){ if(!iso)return''; const d=new Date(iso); return isNaN(d)?iso:d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
 function render(){
-  const f=$('filter').value;
-  const list=ALL.filter(r=>!f||r.room_type===f);
+  const f=$('filter').value, s=$('sort').value;
+  let list=ALL.filter(r=>!f||r.room_type===f);
+  if(s==='pattern'){       // group every render of the same design together; ties → newest first
+    list=list.slice().sort((a,b)=> (b.design_id-a.design_id) || (a.id<b.id?1:-1));
+  } else if(s==='oldest'){ list=list.slice().sort((a,b)=> a.id-b.id);
+  } else if(s==='rtype'){  list=list.slice().sort((a,b)=> (a.room_type||'').localeCompare(b.room_type||'') || (b.id-a.id));
+  } /* newest = server's natural order (id DESC) */
   $('empty').style.display=list.length?'none':'block';
   $('grid').innerHTML=list.map(r=>`
     <div class="card">
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
new file mode 100644
index 0000000..f0b188c
--- /dev/null
+++ b/public/admin/seam-debug.html
@@ -0,0 +1,143 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Seam-defect debugger — wallco.ai admin</title>
+<style>
+  :root{--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;--bad:#ff4d4d;--ok:#7bc96f;--warn:#f0b400;}
+  *{box-sizing:border-box;}
+  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
+  header{position:sticky;top:0;z-index:30;background:rgba(14,15,16,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:9px 14px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
+  h1{font-size:15px;margin:0;font-weight:650;}
+  input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 9px;font:13px ui-monospace,monospace;width:8em;}
+  button{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:13px;cursor:pointer;}
+  button:hover{border-color:var(--ok);}
+  .verdict{font-weight:700;padding:3px 11px;border-radius:999px;font-size:12px;letter-spacing:.04em;}
+  .v-PASS{background:#143b1d;color:var(--ok);border:1px solid #1f5a2a;}
+  .v-WARN{background:#3b2e08;color:var(--warn);border:1px solid #5a4710;}
+  .v-FAIL{background:#3b0e0e;color:var(--bad);border:1px solid #5a1515;}
+  main{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;padding:14px;}
+  @media (max-width:900px){ main{grid-template-columns:1fr;} }
+  .stage{background:#000;border:1px solid var(--line);border-radius:10px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;min-height:520px;}
+  .stage img{display:block;max-width:100%;max-height:80vh;height:auto;}
+  .overlay{position:absolute;pointer-events:none;}
+  .seam-line{position:absolute;background:rgba(123,201,111,.35);}
+  .seam-line.v{width:1px;top:0;bottom:0;}
+  .seam-line.h{height:1px;left:0;right:0;}
+  .box{position:absolute;border:2px solid var(--bad);background:rgba(255,77,77,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,77,77,.45);}
+  .box .lbl{position:absolute;top:-18px;left:0;background:var(--bad);color:#fff;font:11px/1 ui-monospace,monospace;padding:2px 5px;border-radius:3px;white-space:nowrap;}
+  aside{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;align-self:start;}
+  aside h2{font-size:13px;margin:0 0 8px;letter-spacing:.05em;text-transform:uppercase;color:var(--mut);font-weight:600;}
+  aside .row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px dashed var(--line);}
+  aside .row:last-child{border-bottom:none;}
+  aside .row b{font-variant-numeric:tabular-nums;}
+  .summ{padding:10px;background:#0a0b0c;border:1px solid var(--line);border-radius:7px;color:var(--ink);font-size:12.5px;line-height:1.5;margin-top:10px;}
+  .legend{margin-top:14px;font-size:11.5px;color:var(--mut);}
+  .legend .sw{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:middle;}
+  #err{color:#ff9a9a;padding:60px 16px;text-align:center;}
+  .toggle{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--mut);cursor:pointer;}
+  .lens-FAIL{color:var(--bad);} .lens-WARN{color:var(--warn);} .lens-PASS{color:var(--ok);}
+</style>
+</head>
+<body>
+<header>
+  <h1>🔍 Seam-defect debugger</h1>
+  <span>ID</span><input id="idin" type="text" value="">
+  <button onclick="load()">Scan</button>
+  <span id="verdict" class="verdict v-PASS" style="display:none"></span>
+  <span class="pill" id="dims" style="font-size:12px;color:var(--mut)"></span>
+  <label class="toggle"><input type="checkbox" id="t_box" checked>boxes</label>
+  <label class="toggle"><input type="checkbox" id="t_seam" checked>seam-lines</label>
+  <a id="openraw" style="font-size:12px;color:var(--mut);text-decoration:none" target="_blank">open raw image ↗</a>
+</header>
+<div id="err" style="display:none"></div>
+<main id="main">
+  <div class="stage" id="stage">
+    <img id="img" alt="" />
+    <div class="overlay" id="ovl"></div>
+  </div>
+  <aside>
+    <h2>Lens scores (ΔE76)</h2>
+    <div id="lenses"></div>
+    <div class="summ" id="summ"></div>
+    <h2 style="margin-top:14px">Defect boxes</h2>
+    <div id="boxlist" style="font-size:12px;font-family:ui-monospace,monospace;max-height:38vh;overflow:auto"></div>
+    <div class="legend">
+      <div><span class="sw" style="background:rgba(255,77,77,.6);border:1px solid var(--bad)"></span>localized mid-line defect (peak ΔE shown)</div>
+      <div><span class="sw" style="background:rgba(123,201,111,.5)"></span>theoretical mid-line (W/2, H/2)</div>
+      <div style="margin-top:6px;color:var(--mut)">PASS ≤ 5 &nbsp;·&nbsp; WARN ≤ 12 &nbsp;·&nbsp; FAIL &gt; 12. Per-row trigger 8, min run 24 px.</div>
+    </div>
+  </aside>
+</main>
+<script>
+const ADMIN=new URLSearchParams(location.search).get('admin');
+const ID0=new URLSearchParams(location.search).get('id')||location.pathname.match(/\/(\d+)$/)?.[1]||'';
+const $=id=>document.getElementById(id);
+const q=s=>ADMIN?(s+(s.includes('?')?'&':'?')+'admin='+encodeURIComponent(ADMIN)):s;
+$('idin').value=ID0;
+$('t_box').addEventListener('change',redraw);
+$('t_seam').addEventListener('change',redraw);
+addEventListener('keydown',e=>{ if(e.key==='Enter'&&document.activeElement===$('idin')) load(); });
+
+let CUR=null;
+
+function lensRow(name, obj){
+  const score = obj.score!=null?obj.score:obj;
+  const v = score>12?'FAIL':score>5?'WARN':'PASS';
+  return `<div class="row"><span>${name}</span><b class="lens-${v}">${score.toFixed(2)}</b></div>`;
+}
+function redraw(){
+  if(!CUR) return;
+  const ovl=$('ovl'); ovl.innerHTML='';
+  const img=$('img');
+  const iw=img.clientWidth, ih=img.clientHeight;
+  const sx=iw/CUR.width, sy=ih/CUR.height;
+  ovl.style.left=img.offsetLeft+'px'; ovl.style.top=img.offsetTop+'px';
+  ovl.style.width=iw+'px'; ovl.style.height=ih+'px';
+  if($('t_seam').checked){
+    const vline=document.createElement('div'); vline.className='seam-line v'; vline.style.left=(CUR.width/2*sx)+'px'; ovl.appendChild(vline);
+    const hline=document.createElement('div'); hline.className='seam-line h'; hline.style.top=(CUR.height/2*sy)+'px'; ovl.appendChild(hline);
+  }
+  if($('t_box').checked){
+    (CUR.boxes||[]).forEach((b,i)=>{
+      const d=document.createElement('div'); d.className='box';
+      d.style.left=(b.x*sx)+'px'; d.style.top=(b.y*sy)+'px';
+      d.style.width=(b.w*sx)+'px'; d.style.height=(b.h*sy)+'px';
+      d.innerHTML=`<span class="lbl">${b.kind} · ΔE ${b.peak_de}</span>`;
+      d.title=`${b.kind} · x=${b.x} y=${b.y} w=${b.w} h=${b.h} · peak ΔE ${b.peak_de} (mean ${b.mean_de})`;
+      ovl.appendChild(d);
+    });
+  }
+}
+
+async function load(){
+  $('err').style.display='none';
+  const id=$('idin').value.trim(); if(!id) return;
+  history.replaceState(null,'',q(location.pathname.replace(/\/\d+$/,'')+'/'+id));
+  try{
+    const r=await fetch(q(`/api/seam-debug/${id}`));
+    const j=await r.json();
+    if(!r.ok||!j.ok){ $('err').style.display='block'; $('err').textContent='Failed: '+(j.error||r.status); return; }
+    CUR=j;
+    $('verdict').textContent=j.verdict; $('verdict').className='verdict v-'+j.verdict; $('verdict').style.display='inline-block';
+    $('dims').textContent=`${j.width}×${j.height}px`;
+    $('summ').textContent=j.summary;
+    const order=['h_mid','v_mid','top','bottom','left','right'];
+    $('lenses').innerHTML=order.map(k=>{
+      const s=j.scores[k]; const v=s>12?'FAIL':s>5?'WARN':'PASS';
+      return `<div class="row"><span>${k}</span><b class="lens-${v}">${s.toFixed(2)}</b></div>`;
+    }).join('') + `<div class="row" style="margin-top:6px;border-top:1px solid var(--line);padding-top:6px"><span>edges_max</span><b>${j.scores.edges_max.toFixed(2)}</b></div>` +
+      `<div class="row"><span>mids_max</span><b class="lens-${j.scores.mids_max>12?'FAIL':j.scores.mids_max>5?'WARN':'PASS'}">${j.scores.mids_max.toFixed(2)}</b></div>`;
+    $('boxlist').innerHTML = (j.boxes||[]).length
+      ? j.boxes.map((b,i)=>`<div>${String(i+1).padStart(2,'0')}. <b class="lens-FAIL">${b.kind}</b> · x=${b.x} y=${b.y} ${b.w}×${b.h}px · peak ΔE ${b.peak_de} · mean ${b.mean_de}</div>`).join('')
+      : '<div style="color:var(--mut)">no localized defects (mid-line ΔE never exceeded trigger 8 for ≥24 px)</div>';
+    $('img').src=j.image_url; $('openraw').href=j.image_url;
+    $('img').onload=redraw;
+    addEventListener('resize',redraw);
+  }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
+}
+if(ID0) load();
+</script>
+</body>
+</html>
diff --git a/scripts/seam-defect-boxes.py b/scripts/seam-defect-boxes.py
new file mode 100644
index 0000000..e648206
--- /dev/null
+++ b/scripts/seam-defect-boxes.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+"""
+seam-defect-boxes — locate WHERE on a seamless-tile the mid-line seam fails.
+
+Existing `edges-scan.py` reports a single mean ΔE per lens (h_mid, v_mid,
+4 edges). That tells us a tile fails, not where on the tile. This tool
+scans the same seams at row/column granularity and emits bounding boxes
+of the worst segments so the admin viewer can paint them red.
+
+Usage:
+    python3 scripts/seam-defect-boxes.py --id 28859
+    python3 scripts/seam-defect-boxes.py --path /abs/path.png
+
+Output: JSON to stdout.
+    {
+      "ok": true, "id": 28859, "path": "...", "width": W, "height": H,
+      "scores": { "h_mid": 12.6, "v_mid": 9.1,
+                  "top": 2.3, "bottom": 2.4, "left": 3.5, "right": 3.4,
+                  "edges_max": 3.5, "mids_max": 12.6, "overall_max": 12.6 },
+      "verdict": "FAIL" | "WARN" | "PASS",
+      "summary": "...",
+      "boxes": [ { "kind": "v_mid"|"h_mid"|"edge",
+                   "x": ..., "y": ..., "w": ..., "h": ...,
+                   "peak_de": ..., "mean_de": ... } ]
+    }
+
+Box coordinates are absolute pixels in the source image (top-left origin).
+"""
+import argparse, json, os, subprocess, sys
+from pathlib import Path
+
+# Match edges-scan thresholds.
+STRIP_PX = 8
+BAND_PX = 16            # full box width across the seam (8 left + 8 right)
+PASS_MAX = 5.0
+WARN_MAX = 12.0
+ROW_TRIGGER = 8.0       # per-row ΔE that counts as "this row is broken"
+MIN_RUN = 24            # ignore stripes shorter than this many pixels
+MERGE_GAP = 12          # merge runs separated by ≤ this many clean rows
+
+
+def rgb_to_lab(arr):
+    import numpy as np
+    rgb = arr.astype('float32') / 255.0
+    def f(t):
+        return np.where(t > 0.04045, ((t + 0.055) / 1.055) ** 2.4, t / 12.92)
+    rl = f(rgb[..., 0]); gl = f(rgb[..., 1]); bl = f(rgb[..., 2])
+    X = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375
+    Y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750
+    Z = rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041
+    Xn, Yn, Zn = 0.95047, 1.0, 1.08883
+    def fl(t):
+        d = 6 / 29
+        return np.where(t > d ** 3, t ** (1 / 3), t / (3 * d * d) + 4 / 29)
+    fx = fl(X / Xn); fy = fl(Y / Yn); fz = fl(Z / Zn)
+    L = 116 * fy - 16
+    a_ch = 500 * (fx - fy)
+    b_ch = 200 * (fy - fz)
+    return np.stack([L, a_ch, b_ch], axis=-1)
+
+
+def merge_runs(runs, gap):
+    """Merge runs whose gap is ≤ `gap`."""
+    if not runs:
+        return []
+    runs = sorted(runs, key=lambda r: r[0])
+    out = [list(runs[0])]
+    for s, e, peak, mean in runs[1:]:
+        if s - out[-1][1] <= gap:
+            out[-1][1] = max(out[-1][1], e)
+            out[-1][2] = max(out[-1][2], peak)
+            out[-1][3] = max(out[-1][3], mean)
+        else:
+            out.append([s, e, peak, mean])
+    return out
+
+
+def find_runs(de_1d, trigger=ROW_TRIGGER, min_run=MIN_RUN, gap=MERGE_GAP):
+    """Find continuous segments where per-index ΔE > trigger."""
+    import numpy as np
+    flags = de_1d > trigger
+    runs = []
+    i = 0; n = len(flags)
+    while i < n:
+        if flags[i]:
+            j = i
+            while j < n and flags[j]:
+                j += 1
+            seg = de_1d[i:j]
+            runs.append((i, j, float(seg.max()), float(seg.mean())))
+            i = j
+        else:
+            i += 1
+    runs = merge_runs(runs, gap)
+    return [(s, e, p, m) for s, e, p, m in runs if (e - s) >= min_run]
+
+
+def lens_strips(arr, lens):
+    """Return (a, b) strips on each side of the seam."""
+    H, W = arr.shape[:2]
+    if lens == 'h_mid':
+        m = H // 2
+        return arr[m - STRIP_PX:m, :, :], arr[m:m + STRIP_PX, :, :]
+    if lens == 'v_mid':
+        m = W // 2
+        return arr[:, m - STRIP_PX:m, :], arr[:, m:m + STRIP_PX, :]
+    if lens == 'top':
+        return arr[0:STRIP_PX, :, :], arr[H - STRIP_PX:H, :, :]
+    if lens == 'bottom':
+        return arr[H - STRIP_PX:H, :, :], arr[0:STRIP_PX, :, :]
+    if lens == 'left':
+        return arr[:, 0:STRIP_PX, :], arr[:, W - STRIP_PX:W, :]
+    if lens == 'right':
+        return arr[:, W - STRIP_PX:W, :], arr[:, 0:STRIP_PX, :]
+    raise ValueError(lens)
+
+
+def scan(arr):
+    import numpy as np
+    H, W = arr.shape[:2]
+    scores = {}
+    boxes = []
+
+    for lens in ('top', 'bottom', 'left', 'right', 'h_mid', 'v_mid'):
+        a, b = lens_strips(arr, lens)
+        lab_a = rgb_to_lab(a); lab_b = rgb_to_lab(b)
+        de = (((lab_a - lab_b) ** 2).sum(axis=-1)) ** 0.5  # (rows, cols) ΔE map
+        scores[lens] = round(float(de.mean()), 2)
+
+        # Localize ONLY for the midlines (the "fatal" defect Steve flagged).
+        if lens not in ('h_mid', 'v_mid'):
+            continue
+
+        if lens == 'v_mid':
+            # de shape (H, STRIP_PX) — collapse cols → per-row vector along the seam
+            per_row = de.mean(axis=1)
+            for s, e, peak, mean in find_runs(per_row):
+                m = W // 2
+                boxes.append({
+                    'kind': 'v_mid',
+                    'x': max(0, m - BAND_PX // 2),
+                    'y': s,
+                    'w': BAND_PX,
+                    'h': e - s,
+                    'peak_de': round(peak, 2),
+                    'mean_de': round(mean, 2),
+                })
+        else:
+            # h_mid: de shape (STRIP_PX, W) — collapse rows → per-col vector
+            per_col = de.mean(axis=0)
+            for s, e, peak, mean in find_runs(per_col):
+                m = H // 2
+                boxes.append({
+                    'kind': 'h_mid',
+                    'x': s,
+                    'y': max(0, m - BAND_PX // 2),
+                    'w': e - s,
+                    'h': BAND_PX,
+                    'peak_de': round(peak, 2),
+                    'mean_de': round(mean, 2),
+                })
+
+    edges_max = max(scores[k] for k in ('top', 'bottom', 'left', 'right'))
+    mids_max = max(scores['h_mid'], scores['v_mid'])
+    overall_max = max(edges_max, mids_max)
+
+    if overall_max <= PASS_MAX:
+        verdict = 'PASS'
+        summary = 'Clean seamless tile — every lens under PASS threshold.'
+    elif overall_max <= WARN_MAX:
+        verdict = 'WARN'
+        summary = 'Borderline — some lenses in WARN range.'
+    else:
+        verdict = 'FAIL'
+        if mids_max > WARN_MAX and edges_max <= PASS_MAX:
+            summary = ('Internal-grid defect — midline lens(es) > '
+                       f'{WARN_MAX:.0f} while edges are clean. Classic '
+                       'SDXL latent-grid leak.')
+        elif edges_max > WARN_MAX and mids_max <= PASS_MAX:
+            summary = ('Edge-wrap defect — edge lens(es) > '
+                       f'{WARN_MAX:.0f} while midlines clean.')
+        else:
+            summary = 'Multiple lens failures — both edges and midlines flag.'
+
+    scores['edges_max'] = round(edges_max, 2)
+    scores['mids_max'] = round(mids_max, 2)
+    scores['overall_max'] = round(overall_max, 2)
+
+    return {
+        'width': arr.shape[1],
+        'height': arr.shape[0],
+        'scores': scores,
+        'verdict': verdict,
+        'summary': summary,
+        'boxes': boxes,
+    }
+
+
+def scan_path(p: Path):
+    from PIL import Image
+    import numpy as np
+    if not p.exists():
+        return {'ok': False, 'error': f'file not found: {p}'}
+    arr = np.asarray(Image.open(p).convert('RGB'))
+    if arr.ndim != 3 or arr.shape[2] != 3:
+        return {'ok': False, 'error': f'unexpected shape: {arr.shape}'}
+    if min(arr.shape[:2]) < 2 * STRIP_PX + 4:
+        return {'ok': False, 'error': 'image too small'}
+    out = scan(arr)
+    out['ok'] = True
+    out['path'] = str(p)
+    return out
+
+
+def scan_id(design_id: int):
+    sql = f"SELECT local_path FROM spoon_all_designs WHERE id={design_id};"
+    r = subprocess.run(['psql', 'dw_unified', '-At', '-q', '-c', sql],
+                       capture_output=True, text=True, timeout=10)
+    if r.returncode != 0:
+        return {'ok': False, 'error': f'psql failed: {r.stderr.strip()}'}
+    p = (r.stdout or '').strip()
+    if not p:
+        return {'ok': False, 'error': f'no design {design_id}'}
+    out = scan_path(Path(p))
+    out['id'] = design_id
+    return out
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--id', type=int)
+    ap.add_argument('--path')
+    args = ap.parse_args()
+    if args.id:
+        out = scan_id(args.id)
+    elif args.path:
+        out = scan_path(Path(args.path))
+    else:
+        ap.error('--id or --path required')
+    print(json.dumps(out))
+    sys.exit(0 if out.get('ok') else 1)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index 2546cff..abbc763 100644
--- a/server.js
+++ b/server.js
@@ -1143,6 +1143,37 @@ app.get('/api/admin/rooms/list', (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ── Seam-defect localizer — find WHERE on a tile the mid-line seam fails ───
+// edges-scan.py only emits a single ΔE per lens (top/bottom/left/right/
+// h_mid/v_mid). That tells us a tile fails, not where. This pair of routes
+// re-scans at row/column granularity and returns bounding boxes for the
+// worst mid-line segments so the curator can paint them red. The mid-line
+// 2×2 latent-grid leak is fatal — Steve flagged #28859 as an example
+// (mid_score=12.6 / edges=3.5, classic SDXL center-cross).
+const { execFileSync: _execFileSyncSeam } = require('child_process');
+function runSeamScan(id) {
+  const out = _execFileSyncSeam('python3',
+    [path.join(__dirname, 'scripts', 'seam-defect-boxes.py'), '--id', String(id)],
+    { encoding: 'utf8', timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
+  return JSON.parse(out);
+}
+app.get('/api/seam-debug/:id', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!id) return res.status(400).json({ error: 'bad id' });
+  try {
+    const r = runSeamScan(id);
+    if (!r.ok) return res.status(404).json(r);
+    r.image_url = `/designs/img/by-id/${id}`;
+    res.json(r);
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+app.get(['/admin/seam-debug', '/admin/seam-debug/:id'], (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'seam-debug.html'));
+});
+
 // ── /admin/defect-registry — read-only viewer over wallco_defect_registry ──
 // "Keep all bad items in their own db so we never recreate the same error"
 // (Steve 2026-05-27). Browse every catalogued defect with its image, defect

← beb0d0b drunk-curator: sort by triage instance-count desc (AUTO_HOT_  ·  back to Wallco Ai  ·  seam-debug: localize ALL joints (4 edge-wrap pairs + 2 inter 5a5d6f7 →