Ox1 Cursor commited on
Commit
2d97bc2
·
1 Parent(s): 7235725

feat(ui): manual garment crop with Annotorious bounding-box editor

Browse files

Upload a photo → server auto-detects garments via YOLOS and returns
bounding boxes → Annotorious v3.8.6 renders the image with the boxes
pre-drawn → user adds/edits/deletes boxes → "Analyse N garments"
crops each box server-side and runs the VLM on each crop.

Backend (app.py):
- Mount data/_uploads/ as /uploads (temp image store).
- api_prepare_image: saves upload, runs detect_garments, returns
token + image_url + detected boxes [{x,y,w,h}].
- api_analyze_boxes: validates token, crops each box via PIL,
calls extract_from_crop_bytes per crop, adds to catalog, cleans up.

Frontend (index.html):
- Load @annotorious/annotorious@3.8.6 CSS + ESM (version pinned).
- handleFile now calls /prepare_image then opens the editor.
- initAnnotator: creates annotator, loads initial boxes as W3C
FragmentSelector annotations, syncs box count on create/delete.
- analyzeBoxes: reads annotations, maps to pixel coords, calls
/analyze_boxes, refreshes wardrobe on success.
- cancelEditor: destroys annotator, resets editor state.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (3) hide show
  1. app.py +111 -0
  2. src/ui/index.html +152 -7
  3. src/ui/style.css +38 -0
app.py CHANGED
@@ -813,6 +813,10 @@ def _build_custom_server():
813
 
814
  server.mount("/garments", StaticFiles(directory=str(_DATA_DIR / "garments")), name="garments")
815
 
 
 
 
 
816
  def _image_url(garment_id: str) -> str:
817
  """Return a cache-busted URL for a garment image using file mtime."""
818
  img_path = get_image_path(garment_id)
@@ -825,6 +829,113 @@ def _build_custom_server():
825
  v = 0
826
  return f"/garments/{garment_id}.jpg?v={v}"
827
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  @server.api(name="get_wardrobe")
829
  def api_get_wardrobe() -> dict:
830
  catalog = load_catalog()
 
813
 
814
  server.mount("/garments", StaticFiles(directory=str(_DATA_DIR / "garments")), name="garments")
815
 
816
+ _UPLOADS_DIR = _DATA_DIR / "_uploads"
817
+ _UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
818
+ server.mount("/uploads", StaticFiles(directory=str(_UPLOADS_DIR)), name="uploads")
819
+
820
  def _image_url(garment_id: str) -> str:
821
  """Return a cache-busted URL for a garment image using file mtime."""
822
  img_path = get_image_path(garment_id)
 
829
  v = 0
830
  return f"/garments/{garment_id}.jpg?v={v}"
831
 
832
+ @server.api(name="prepare_image")
833
+ def api_prepare_image(image_path: str | dict) -> dict:
834
+ """Save image to _uploads, run auto-detection, return token + detected boxes.
835
+
836
+ The frontend uses the token + image_url to show the image in Annotorious
837
+ pre-populated with auto-detected boxes for the user to review/edit.
838
+ """
839
+ import uuid
840
+ import time as _time
841
+
842
+ if isinstance(image_path, dict):
843
+ image_path = image_path.get("path") or image_path.get("url", "")
844
+
845
+ try:
846
+ img = Image.open(str(image_path)).convert("RGB")
847
+ except Exception as e:
848
+ return {"error": str(e), "token": "", "image_url": "", "width": 0, "height": 0, "boxes": []}
849
+
850
+ img.thumbnail((1280, 1280), Image.LANCZOS)
851
+ w, h = img.size
852
+
853
+ token = uuid.uuid4().hex
854
+ upload_path = _UPLOADS_DIR / f"{token}.jpg"
855
+ img.save(str(upload_path), format="JPEG", quality=90)
856
+
857
+ try:
858
+ boxes = detect_boxes(str(upload_path))
859
+ except Exception:
860
+ boxes = []
861
+
862
+ ts = int(_time.time())
863
+ return {
864
+ "token": token,
865
+ "image_url": f"/uploads/{token}.jpg?v={ts}",
866
+ "width": w,
867
+ "height": h,
868
+ "boxes": [{"x": b.x1, "y": b.y1, "w": b.width, "h": b.height} for b in boxes],
869
+ }
870
+
871
+ @server.api(name="analyze_boxes")
872
+ def api_analyze_boxes(token: str, boxes: str) -> dict:
873
+ """Crop each user-confirmed bounding box from the uploaded image and extract garment attributes.
874
+
875
+ Args:
876
+ token: filename token returned by prepare_image (hex, no path separators).
877
+ boxes: JSON string — list of {x, y, w, h} in pixels of the stored image.
878
+ """
879
+ import json as _json
880
+ import re as _re
881
+
882
+ # Validate token: only hex characters, no path traversal
883
+ if not _re.fullmatch(r"[0-9a-f]{32}", token):
884
+ return {"error": "Invalid token", "count": 0, "garments": []}
885
+
886
+ upload_path = _UPLOADS_DIR / f"{token}.jpg"
887
+ if not upload_path.exists():
888
+ return {"error": "Image not found. Please re-upload.", "count": 0, "garments": []}
889
+
890
+ try:
891
+ box_list = _json.loads(boxes)
892
+ except Exception:
893
+ return {"error": "Invalid boxes format", "count": 0, "garments": []}
894
+
895
+ try:
896
+ img = Image.open(str(upload_path)).convert("RGB")
897
+ except Exception as e:
898
+ return {"error": str(e), "count": 0, "garments": []}
899
+
900
+ results: list[tuple[dict, bytes]] = []
901
+ for box in box_list:
902
+ try:
903
+ x = int(box["x"])
904
+ y = int(box["y"])
905
+ w = int(box["w"])
906
+ h = int(box["h"])
907
+ except (KeyError, TypeError, ValueError):
908
+ continue
909
+
910
+ if w < 10 or h < 10:
911
+ continue
912
+
913
+ cropped = img.crop((x, y, x + w, y + h))
914
+ cropped.thumbnail((512, 512), Image.LANCZOS)
915
+
916
+ buf = io.BytesIO()
917
+ cropped.save(buf, format="JPEG", quality=85)
918
+ crop_bytes = buf.getvalue()
919
+
920
+ result = extract_from_crop_bytes(crop_bytes)
921
+ if result:
922
+ results.append(result)
923
+
924
+ # Cleanup upload temp file
925
+ try:
926
+ upload_path.unlink()
927
+ except OSError:
928
+ pass
929
+
930
+ if not results:
931
+ return {"error": "No garments could be extracted from the selections.", "count": 0, "garments": []}
932
+
933
+ added = add_garments(results)
934
+ for g in added:
935
+ g["image_url"] = _image_url(g["id"])
936
+
937
+ return {"count": len(added), "garments": added}
938
+
939
  @server.api(name="get_wardrobe")
940
  def api_get_wardrobe() -> dict:
941
  catalog = load_catalog()
src/ui/index.html CHANGED
@@ -5,11 +5,14 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Wardrobe AI</title>
7
  <link rel="stylesheet" href="/style.css">
 
8
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
10
  <script type="module">
11
  import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
 
12
  window.gradioClient = null;
 
13
  Client.connect(window.location.origin).then(c => { window.gradioClient = c; });
14
  </script>
15
  </head>
@@ -107,6 +110,24 @@
107
 
108
  <div class="status" :class="uploadStatus.type" x-show="uploadStatus.message" x-text="uploadStatus.message"></div>
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  <div style="margin-top:2rem">
111
  <h2>Or load a sample dataset</h2>
112
  <div class="dataset-row">
@@ -220,6 +241,16 @@
220
  // Garment detail
221
  selectedGarment: null,
222
 
 
 
 
 
 
 
 
 
 
 
223
  // Outfits
224
  context: '',
225
  outfits: [],
@@ -252,17 +283,28 @@
252
 
253
  async handleFile(file) {
254
  if (!file) return;
255
- this.uploadStatus = { type: 'loading', message: 'Analyzing your clothes...' };
 
 
 
256
  try {
257
  const { handle_file } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
258
- const result = await window.gradioClient.predict("/add_photo", { image_path: handle_file(file) });
259
  const data = result.data[0];
260
- if (data.count > 0) {
261
- this.uploadStatus = { type: 'success', message: `Found ${data.count} garment${data.count > 1 ? 's' : ''}! Check your wardrobe.` };
262
- await this.loadWardrobe();
263
- } else {
264
- this.uploadStatus = { type: 'error', message: "Couldn't detect any garments. Try a clearer image." };
265
  }
 
 
 
 
 
 
 
 
 
266
  } catch (e) {
267
  this.uploadStatus = { type: 'error', message: 'Something went wrong. Please try again.' };
268
  console.error(e);
@@ -385,6 +427,109 @@
385
  return div.innerHTML;
386
  },
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  selectGarment(g) {
389
  this.selectedGarment = g;
390
  },
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Wardrobe AI</title>
7
  <link rel="stylesheet" href="/style.css">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/dist/annotorious.css">
9
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
10
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
11
  <script type="module">
12
  import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
13
+ import { createImageAnnotator } from "https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/+esm";
14
  window.gradioClient = null;
15
+ window.annotoriousFactory = createImageAnnotator;
16
  Client.connect(window.location.origin).then(c => { window.gradioClient = c; });
17
  </script>
18
  </head>
 
110
 
111
  <div class="status" :class="uploadStatus.type" x-show="uploadStatus.message" x-text="uploadStatus.message"></div>
112
 
113
+ <!-- Bounding box editor (shown after upload) -->
114
+ <div class="editor-area" x-show="editorActive">
115
+ <p class="editor-hint">
116
+ <strong x-text="editorAutoBoxCount > 0 ? `${editorAutoBoxCount} garment${editorAutoBoxCount > 1 ? 's' : ''} auto-detected.` : 'No garments auto-detected.'"></strong>
117
+ Draw rectangles over any missing ones, adjust or delete existing boxes, then click Analyse.
118
+ </p>
119
+ <div class="editor-canvas-wrap">
120
+ <img id="annoImage" :src="editorImageUrl" alt="Garment photo">
121
+ </div>
122
+ <div class="editor-actions">
123
+ <button class="btn btn-primary" @click="analyzeBoxes()" :disabled="analyzing || editorBoxCount === 0">
124
+ <span x-text="analyzing ? 'Analysing…' : `Analyse ${editorBoxCount} garment${editorBoxCount !== 1 ? 's' : ''}`"></span>
125
+ </button>
126
+ <button class="btn btn-secondary" @click="cancelEditor()" :disabled="analyzing">Cancel</button>
127
+ </div>
128
+ <div class="status" :class="analyzeStatus.type" x-show="analyzeStatus.message" x-text="analyzeStatus.message"></div>
129
+ </div>
130
+
131
  <div style="margin-top:2rem">
132
  <h2>Or load a sample dataset</h2>
133
  <div class="dataset-row">
 
241
  // Garment detail
242
  selectedGarment: null,
243
 
244
+ // Bounding-box editor
245
+ editorActive: false,
246
+ editorImageUrl: '',
247
+ editorToken: '',
248
+ editorBoxCount: 0,
249
+ editorAutoBoxCount: 0,
250
+ analyzing: false,
251
+ analyzeStatus: { type: '', message: '' },
252
+ anno: null,
253
+
254
  // Outfits
255
  context: '',
256
  outfits: [],
 
283
 
284
  async handleFile(file) {
285
  if (!file) return;
286
+ this.uploadStatus = { type: 'loading', message: 'Detecting garments…' };
287
+ this.editorActive = false;
288
+ this.analyzeStatus = { type: '', message: '' };
289
+
290
  try {
291
  const { handle_file } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
292
+ const result = await window.gradioClient.predict("/prepare_image", { image_path: handle_file(file) });
293
  const data = result.data[0];
294
+
295
+ if (data.error) {
296
+ this.uploadStatus = { type: 'error', message: data.error };
297
+ return;
 
298
  }
299
+
300
+ this.uploadStatus = { type: '', message: '' };
301
+ this.editorToken = data.token;
302
+ this.editorImageUrl = data.image_url;
303
+ this.editorAutoBoxCount = data.boxes.length;
304
+ this.editorBoxCount = data.boxes.length;
305
+ this.editorActive = true;
306
+
307
+ this.$nextTick(() => this.initAnnotator(data.boxes, data.width, data.height));
308
  } catch (e) {
309
  this.uploadStatus = { type: 'error', message: 'Something went wrong. Please try again.' };
310
  console.error(e);
 
427
  return div.innerHTML;
428
  },
429
 
430
+ initAnnotator(boxes, imgW, imgH) {
431
+ // Destroy any previous instance
432
+ if (this.anno) {
433
+ try { this.anno.destroy(); } catch (_) {}
434
+ this.anno = null;
435
+ }
436
+
437
+ const factory = window.annotoriousFactory;
438
+ if (!factory) {
439
+ console.error('Annotorious not loaded yet');
440
+ return;
441
+ }
442
+
443
+ const el = document.getElementById('annoImage');
444
+ if (!el) return;
445
+
446
+ this.anno = factory(el, {
447
+ drawingEnabled: true,
448
+ drawingTool: 'rectangle',
449
+ style: { fill: '#3b82f6', fillOpacity: 0.15, stroke: '#3b82f6', strokeWidth: 2 },
450
+ });
451
+
452
+ // Load auto-detected boxes as initial annotations (W3C Web Annotation format)
453
+ if (boxes.length > 0) {
454
+ const annotations = boxes.map((b, i) => ({
455
+ id: `auto-${i}`,
456
+ type: 'Annotation',
457
+ target: {
458
+ selector: {
459
+ type: 'FragmentSelector',
460
+ conformsTo: 'http://www.w3.org/TR/media-frags/',
461
+ value: `xywh=pixel:${b.x},${b.y},${b.w},${b.h}`,
462
+ },
463
+ },
464
+ body: [],
465
+ }));
466
+ this.anno.setAnnotations(annotations);
467
+ }
468
+
469
+ // Keep editorBoxCount in sync
470
+ const updateCount = () => {
471
+ this.editorBoxCount = this.anno.getAnnotations().length;
472
+ };
473
+ this.anno.on('createAnnotation', updateCount);
474
+ this.anno.on('deleteAnnotation', updateCount);
475
+ this.anno.on('updateAnnotation', updateCount);
476
+ },
477
+
478
+ async analyzeBoxes() {
479
+ if (!this.anno || this.editorBoxCount === 0) return;
480
+ this.analyzing = true;
481
+ this.analyzeStatus = { type: 'loading', message: 'Extracting garment attributes…' };
482
+
483
+ // Map W3C annotations to {x,y,w,h} pixel boxes
484
+ const annotations = this.anno.getAnnotations();
485
+ const boxes = annotations.map(ann => {
486
+ // FragmentSelector: "xywh=pixel:x,y,w,h"
487
+ const sel = ann.target?.selector;
488
+ if (sel?.type === 'FragmentSelector') {
489
+ const m = sel.value.match(/xywh=pixel:([\d.]+),([\d.]+),([\d.]+),([\d.]+)/);
490
+ if (m) return { x: Math.round(+m[1]), y: Math.round(+m[2]), w: Math.round(+m[3]), h: Math.round(+m[4]) };
491
+ }
492
+ return null;
493
+ }).filter(Boolean);
494
+
495
+ if (boxes.length === 0) {
496
+ this.analyzeStatus = { type: 'error', message: 'No valid boxes to analyze.' };
497
+ this.analyzing = false;
498
+ return;
499
+ }
500
+
501
+ try {
502
+ const result = await window.gradioClient.predict("/analyze_boxes", {
503
+ token: this.editorToken,
504
+ boxes: JSON.stringify(boxes),
505
+ });
506
+ const data = result.data[0];
507
+ if (data.error) {
508
+ this.analyzeStatus = { type: 'error', message: data.error };
509
+ } else {
510
+ this.analyzeStatus = { type: 'success', message: `Added ${data.count} garment${data.count !== 1 ? 's' : ''} to your wardrobe!` };
511
+ await this.loadWardrobe();
512
+ this.cancelEditor();
513
+ }
514
+ } catch (e) {
515
+ this.analyzeStatus = { type: 'error', message: 'Something went wrong. Please try again.' };
516
+ console.error(e);
517
+ }
518
+ this.analyzing = false;
519
+ },
520
+
521
+ cancelEditor() {
522
+ if (this.anno) {
523
+ try { this.anno.destroy(); } catch (_) {}
524
+ this.anno = null;
525
+ }
526
+ this.editorActive = false;
527
+ this.editorToken = '';
528
+ this.editorImageUrl = '';
529
+ this.editorBoxCount = 0;
530
+ this.editorAutoBoxCount = 0;
531
+ },
532
+
533
  selectGarment(g) {
534
  this.selectedGarment = g;
535
  },
src/ui/style.css CHANGED
@@ -629,6 +629,44 @@ nav button.active {
629
  font-weight: 500;
630
  }
631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  /* Log dock — fixed bottom panel */
633
  .log-dock {
634
  position: fixed;
 
629
  font-weight: 500;
630
  }
631
 
632
+ /* Bounding box editor */
633
+ .editor-area {
634
+ margin-top: 1.5rem;
635
+ }
636
+
637
+ .editor-hint {
638
+ font-size: 0.85rem;
639
+ color: var(--text-muted);
640
+ margin-bottom: 0.85rem;
641
+ line-height: 1.5;
642
+ }
643
+
644
+ .editor-hint strong {
645
+ color: var(--text);
646
+ }
647
+
648
+ .editor-canvas-wrap {
649
+ position: relative;
650
+ max-height: 60vh;
651
+ overflow: auto;
652
+ border: 1px solid var(--border);
653
+ border-radius: var(--radius-sm);
654
+ background: #f3f4f6;
655
+ }
656
+
657
+ .editor-canvas-wrap #annoImage {
658
+ display: block;
659
+ max-width: 100%;
660
+ height: auto;
661
+ }
662
+
663
+ .editor-actions {
664
+ display: flex;
665
+ gap: 0.75rem;
666
+ margin-top: 1rem;
667
+ flex-wrap: wrap;
668
+ }
669
+
670
  /* Log dock — fixed bottom panel */
671
  .log-dock {
672
  position: fixed;