Spaces:
Runtime error
feat(ui): manual garment crop with Annotorious bounding-box editor
Browse filesUpload 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>
- app.py +111 -0
- src/ui/index.html +152 -7
- src/ui/style.css +38 -0
|
@@ -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()
|
|
@@ -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: '
|
|
|
|
|
|
|
|
|
|
| 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("/
|
| 259 |
const data = result.data[0];
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 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 |
},
|
|
@@ -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;
|