Ox1 Cursor commited on
Commit
ee5ec33
·
1 Parent(s): d099a59

feat(ui): garment detail panel and real-time load logs

Browse files

- Clicking a wardrobe card opens an overlay with the full garment
attributes (type, color, material, pattern, season, formality) and
the AI-generated natural-language description.
- api_load_dataset converted from a blocking call to a streaming
generator that yields progress per garment (count, log lines,
preview URL).
- loadDataset() in the frontend now uses gradioClient.submit() to
iterate SSE events; a live log console (dark monospace) and a row
of thumbnail previews update in real time as each garment is
processed.
- Wardrobe is refreshed automatically once loading completes.

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

Files changed (3) hide show
  1. app.py +36 -6
  2. src/ui/index.html +104 -8
  3. src/ui/style.css +186 -0
app.py CHANGED
@@ -863,7 +863,12 @@ def _build_custom_server():
863
  return ask(question.strip())
864
 
865
  @server.api(name="load_dataset")
866
- def api_load_dataset(dataset_key: str) -> dict:
 
 
 
 
 
867
  from datasets import load_dataset as hf_load
868
 
869
  ds_configs = {
@@ -872,10 +877,22 @@ def _build_custom_server():
872
  }
873
  config = ds_configs.get(dataset_key)
874
  if not config:
875
- return {"error": "Unknown dataset", "count": 0}
 
 
 
 
 
 
 
 
 
 
876
 
877
- ds = hf_load(config["hf_id"], split="train")
878
- target = 50
 
 
879
  step = max(1, len(ds) // (target * 2))
880
  indices = list(range(0, len(ds), step))[:target * 3]
881
  garments_processed: list[tuple[dict, bytes]] = []
@@ -910,6 +927,11 @@ def _build_custom_server():
910
  garment = _extract_single_garment(crop_bytes)
911
  if garment:
912
  garments_processed.append((garment, crop_bytes))
 
 
 
 
 
913
  else:
914
  if max(image.size) > 512:
915
  image.thumbnail((512, 512), Image.LANCZOS)
@@ -919,11 +941,19 @@ def _build_custom_server():
919
  garment = _extract_single_garment(crop_bytes)
920
  if garment:
921
  garments_processed.append((garment, crop_bytes))
 
 
 
 
 
922
 
923
  if not garments_processed:
924
- return {"error": "No garments extracted", "count": 0}
 
 
925
  added = add_garments(garments_processed)
926
- return {"count": len(added), "garments": [g.get("id") for g in added]}
 
927
 
928
  @server.get("/")
929
  async def homepage():
 
863
  return ask(question.strip())
864
 
865
  @server.api(name="load_dataset")
866
+ def api_load_dataset(dataset_key: str):
867
+ """Stream dataset processing progress as each garment is analyzed.
868
+
869
+ Yields progress dicts with: done, count, log[], preview_url.
870
+ The final yield has done=True with the total count.
871
+ """
872
  from datasets import load_dataset as hf_load
873
 
874
  ds_configs = {
 
877
  }
878
  config = ds_configs.get(dataset_key)
879
  if not config:
880
+ yield {"done": True, "error": "Unknown dataset", "count": 0, "log": ["Error: dataset not recognized."], "preview_url": None}
881
+ return
882
+
883
+ log_lines: list[str] = [f"Downloading {config['hf_id']}..."]
884
+ yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None}
885
+
886
+ try:
887
+ ds = hf_load(config["hf_id"], split="train")
888
+ except Exception as e:
889
+ yield {"done": True, "error": str(e), "count": 0, "log": [f"Error downloading dataset: {e}"], "preview_url": None}
890
+ return
891
 
892
+ log_lines.append(f"Dataset loaded: {len(ds)} images. Starting processing...")
893
+ yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None}
894
+
895
+ target = TARGET_GARMENTS
896
  step = max(1, len(ds) // (target * 2))
897
  indices = list(range(0, len(ds), step))[:target * 3]
898
  garments_processed: list[tuple[dict, bytes]] = []
 
927
  garment = _extract_single_garment(crop_bytes)
928
  if garment:
929
  garments_processed.append((garment, crop_bytes))
930
+ n = len(garments_processed)
931
+ preview_path = _save_temp_preview(crop_bytes, n)
932
+ preview_url = f"/garments/_preview_{n:03d}.jpg" if preview_path else None
933
+ log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
934
+ yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
935
  else:
936
  if max(image.size) > 512:
937
  image.thumbnail((512, 512), Image.LANCZOS)
 
941
  garment = _extract_single_garment(crop_bytes)
942
  if garment:
943
  garments_processed.append((garment, crop_bytes))
944
+ n = len(garments_processed)
945
+ preview_path = _save_temp_preview(crop_bytes, n)
946
+ preview_url = f"/garments/_preview_{n:03d}.jpg" if preview_path else None
947
+ log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
948
+ yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
949
 
950
  if not garments_processed:
951
+ yield {"done": True, "error": "No garments extracted", "count": 0, "log": log_lines + ["No garments could be extracted."], "preview_url": None}
952
+ return
953
+
954
  added = add_garments(garments_processed)
955
+ log_lines.append(f"Done: {len(added)} garments added to your wardrobe.")
956
+ yield {"done": True, "count": len(added), "log": log_lines[-12:], "preview_url": None}
957
 
958
  @server.get("/")
959
  async def homepage():
src/ui/index.html CHANGED
@@ -33,7 +33,7 @@
33
 
34
  <div class="garment-grid" x-show="garments.length > 0">
35
  <template x-for="g in garments" :key="g.id">
36
- <div class="garment-card">
37
  <img :src="g.image_url" :alt="g.type" loading="lazy">
38
  <div class="info">
39
  <div class="type" x-text="g.type || 'garment'"></div>
@@ -49,6 +49,46 @@
49
  </div>
50
  </section>
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  <!-- Add Clothes -->
53
  <section x-show="tab === 'add'">
54
  <h2>Add Clothes</h2>
@@ -79,6 +119,18 @@
79
  </button>
80
  </div>
81
  <div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
 
 
 
 
 
 
 
 
 
 
 
 
82
  </div>
83
  </section>
84
 
@@ -149,6 +201,12 @@
149
  datasetKey: 'second-hand',
150
  datasetLoading: false,
151
  datasetStatus: { type: '', message: '' },
 
 
 
 
 
 
152
 
153
  // Outfits
154
  context: '',
@@ -200,14 +258,44 @@
200
 
201
  async loadDataset() {
202
  this.datasetLoading = true;
203
- this.datasetStatus = { type: 'loading', message: 'Loading dataset... This may take a few minutes.' };
 
 
 
 
204
  try {
205
- const result = await window.gradioClient.predict("/load_dataset", { dataset_key: this.datasetKey });
206
- const data = result.data[0];
207
- if (data.error) {
208
- this.datasetStatus = { type: 'error', message: data.error };
209
- } else {
210
- this.datasetStatus = { type: 'success', message: `Loaded ${data.count} garments into your wardrobe!` };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
  } catch (e) {
213
  this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' };
@@ -282,6 +370,14 @@
282
  div.textContent = str;
283
  return div.innerHTML;
284
  },
 
 
 
 
 
 
 
 
285
  };
286
  }
287
  </script>
 
33
 
34
  <div class="garment-grid" x-show="garments.length > 0">
35
  <template x-for="g in garments" :key="g.id">
36
+ <div class="garment-card" @click="selectGarment(g)">
37
  <img :src="g.image_url" :alt="g.type" loading="lazy">
38
  <div class="info">
39
  <div class="type" x-text="g.type || 'garment'"></div>
 
49
  </div>
50
  </section>
51
 
52
+ <!-- Garment Detail Overlay -->
53
+ <div class="detail-overlay" x-show="selectedGarment" @click.self="closeDetail()" @keydown.escape.window="closeDetail()">
54
+ <div class="detail-panel" x-show="selectedGarment">
55
+ <button class="detail-close" @click="closeDetail()" aria-label="Close">✕</button>
56
+ <div class="detail-image">
57
+ <img :src="selectedGarment?.image_url" :alt="selectedGarment?.type" loading="lazy">
58
+ </div>
59
+ <div class="detail-info">
60
+ <h3 class="detail-title" x-text="selectedGarment ? `${selectedGarment.color} ${selectedGarment.type}` : ''"></h3>
61
+ <p class="detail-description" x-show="selectedGarment?.description" x-text="selectedGarment?.description"></p>
62
+ <div class="attr-list">
63
+ <div class="attr-row">
64
+ <span class="attr-key">Type</span>
65
+ <span class="attr-val" x-text="selectedGarment?.type || '—'"></span>
66
+ </div>
67
+ <div class="attr-row">
68
+ <span class="attr-key">Color</span>
69
+ <span class="attr-val" x-text="selectedGarment?.color || '—'"></span>
70
+ </div>
71
+ <div class="attr-row">
72
+ <span class="attr-key">Material</span>
73
+ <span class="attr-val" x-text="selectedGarment?.material || '—'"></span>
74
+ </div>
75
+ <div class="attr-row">
76
+ <span class="attr-key">Pattern</span>
77
+ <span class="attr-val" x-text="selectedGarment?.pattern || '—'"></span>
78
+ </div>
79
+ <div class="attr-row">
80
+ <span class="attr-key">Season</span>
81
+ <span class="attr-val" x-text="selectedGarment?.season || '—'"></span>
82
+ </div>
83
+ <div class="attr-row">
84
+ <span class="attr-key">Formality</span>
85
+ <span class="attr-val" x-text="selectedGarment?.formality || '—'"></span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
  <!-- Add Clothes -->
93
  <section x-show="tab === 'add'">
94
  <h2>Add Clothes</h2>
 
119
  </button>
120
  </div>
121
  <div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
122
+
123
+ <div class="log-console" x-show="datasetLog.length > 0" x-ref="logConsole">
124
+ <template x-for="(line, i) in datasetLog" :key="i">
125
+ <div class="log-line" x-text="line"></div>
126
+ </template>
127
+ </div>
128
+
129
+ <div class="dataset-previews" x-show="datasetPreviews.length > 0">
130
+ <template x-for="(url, i) in datasetPreviews" :key="i">
131
+ <img :src="url" :alt="`Garment ${i + 1}`" loading="lazy">
132
+ </template>
133
+ </div>
134
  </div>
135
  </section>
136
 
 
201
  datasetKey: 'second-hand',
202
  datasetLoading: false,
203
  datasetStatus: { type: '', message: '' },
204
+ datasetLog: [],
205
+ datasetPreviews: [],
206
+ datasetCount: 0,
207
+
208
+ // Garment detail
209
+ selectedGarment: null,
210
 
211
  // Outfits
212
  context: '',
 
258
 
259
  async loadDataset() {
260
  this.datasetLoading = true;
261
+ this.datasetLog = [];
262
+ this.datasetPreviews = [];
263
+ this.datasetCount = 0;
264
+ this.datasetStatus = { type: 'loading', message: 'Starting...' };
265
+
266
  try {
267
+ const job = window.gradioClient.submit("/load_dataset", { dataset_key: this.datasetKey });
268
+ for await (const event of job) {
269
+ if (event.type !== "data") continue;
270
+ const data = event.data[0];
271
+ if (!data) continue;
272
+
273
+ if (Array.isArray(data.log) && data.log.length) {
274
+ this.datasetLog = data.log;
275
+ this.$nextTick(() => {
276
+ const el = this.$refs.logConsole;
277
+ if (el) el.scrollTop = el.scrollHeight;
278
+ });
279
+ }
280
+ if (typeof data.count === "number") this.datasetCount = data.count;
281
+ if (data.preview_url && !this.datasetPreviews.includes(data.preview_url)) {
282
+ this.datasetPreviews.push(data.preview_url);
283
+ }
284
+
285
+ if (data.done) {
286
+ if (data.error) {
287
+ this.datasetStatus = { type: 'error', message: data.error };
288
+ } else {
289
+ this.datasetStatus = { type: 'success', message: `Loaded ${data.count} garments into your wardrobe!` };
290
+ await this.loadWardrobe();
291
+ }
292
+ break;
293
+ } else {
294
+ this.datasetStatus = {
295
+ type: 'loading',
296
+ message: data.count > 0 ? `Processing garments... ${data.count}/50` : 'Downloading dataset...',
297
+ };
298
+ }
299
  }
300
  } catch (e) {
301
  this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' };
 
370
  div.textContent = str;
371
  return div.innerHTML;
372
  },
373
+
374
+ selectGarment(g) {
375
+ this.selectedGarment = g;
376
+ },
377
+
378
+ closeDetail() {
379
+ this.selectedGarment = null;
380
+ },
381
  };
382
  }
383
  </script>
src/ui/style.css CHANGED
@@ -137,6 +137,10 @@ section h2 .count {
137
  transition: transform var(--transition), box-shadow var(--transition);
138
  }
139
 
 
 
 
 
140
  .garment-card:hover {
141
  transform: translateY(-2px);
142
  box-shadow: var(--shadow-lg);
@@ -509,6 +513,165 @@ nav button.active {
509
  display: none !important;
510
  }
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  @media (max-width: 640px) {
513
  .app {
514
  padding: 1.5rem 1rem 3rem;
@@ -525,4 +688,27 @@ nav button.active {
525
  .context-input {
526
  flex-direction: column;
527
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  }
 
137
  transition: transform var(--transition), box-shadow var(--transition);
138
  }
139
 
140
+ .garment-card {
141
+ cursor: pointer;
142
+ }
143
+
144
  .garment-card:hover {
145
  transform: translateY(-2px);
146
  box-shadow: var(--shadow-lg);
 
513
  display: none !important;
514
  }
515
 
516
+ /* Garment detail overlay */
517
+ .detail-overlay {
518
+ position: fixed;
519
+ inset: 0;
520
+ background: rgba(0, 0, 0, 0.5);
521
+ z-index: 100;
522
+ display: flex;
523
+ align-items: center;
524
+ justify-content: center;
525
+ padding: 1.5rem;
526
+ }
527
+
528
+ .detail-panel {
529
+ background: var(--surface);
530
+ border-radius: var(--radius);
531
+ box-shadow: var(--shadow-lg);
532
+ max-width: 600px;
533
+ width: 100%;
534
+ max-height: 90vh;
535
+ overflow-y: auto;
536
+ display: grid;
537
+ grid-template-columns: 1fr 1fr;
538
+ grid-template-rows: auto 1fr;
539
+ position: relative;
540
+ }
541
+
542
+ .detail-close {
543
+ position: absolute;
544
+ top: 0.75rem;
545
+ right: 0.75rem;
546
+ background: none;
547
+ border: 1px solid var(--border);
548
+ border-radius: 50%;
549
+ width: 32px;
550
+ height: 32px;
551
+ cursor: pointer;
552
+ font-size: 0.85rem;
553
+ color: var(--text-muted);
554
+ display: flex;
555
+ align-items: center;
556
+ justify-content: center;
557
+ transition: all var(--transition);
558
+ z-index: 1;
559
+ }
560
+
561
+ .detail-close:hover {
562
+ background: #f3f4f6;
563
+ color: var(--text);
564
+ }
565
+
566
+ .detail-image {
567
+ grid-column: 1;
568
+ grid-row: 1 / 3;
569
+ }
570
+
571
+ .detail-image img {
572
+ width: 100%;
573
+ height: 100%;
574
+ object-fit: cover;
575
+ border-radius: var(--radius) 0 0 var(--radius);
576
+ background: #f3f4f6;
577
+ display: block;
578
+ min-height: 260px;
579
+ }
580
+
581
+ .detail-info {
582
+ grid-column: 2;
583
+ grid-row: 1 / 3;
584
+ padding: 1.5rem 1.5rem 1.5rem 1.25rem;
585
+ }
586
+
587
+ .detail-title {
588
+ font-size: 1rem;
589
+ font-weight: 600;
590
+ text-transform: capitalize;
591
+ margin-bottom: 0.5rem;
592
+ padding-right: 2rem;
593
+ }
594
+
595
+ .detail-description {
596
+ font-size: 0.82rem;
597
+ color: var(--text-muted);
598
+ line-height: 1.5;
599
+ margin-bottom: 1rem;
600
+ font-style: italic;
601
+ }
602
+
603
+ .attr-list {
604
+ display: flex;
605
+ flex-direction: column;
606
+ gap: 0.4rem;
607
+ }
608
+
609
+ .attr-row {
610
+ display: flex;
611
+ justify-content: space-between;
612
+ align-items: baseline;
613
+ font-size: 0.82rem;
614
+ padding: 0.35rem 0;
615
+ border-bottom: 1px solid var(--border);
616
+ }
617
+
618
+ .attr-row:last-child {
619
+ border-bottom: none;
620
+ }
621
+
622
+ .attr-key {
623
+ color: var(--text-muted);
624
+ font-weight: 500;
625
+ flex-shrink: 0;
626
+ margin-right: 0.75rem;
627
+ }
628
+
629
+ .attr-val {
630
+ text-transform: capitalize;
631
+ text-align: right;
632
+ font-weight: 500;
633
+ }
634
+
635
+ /* Log console */
636
+ .log-console {
637
+ background: #1e1e2e;
638
+ border-radius: var(--radius-sm);
639
+ padding: 0.75rem 1rem;
640
+ margin-top: 0.75rem;
641
+ max-height: 180px;
642
+ overflow-y: auto;
643
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
644
+ font-size: 0.78rem;
645
+ line-height: 1.6;
646
+ scroll-behavior: smooth;
647
+ }
648
+
649
+ .log-line {
650
+ color: #a6e3a1;
651
+ white-space: pre-wrap;
652
+ word-break: break-all;
653
+ }
654
+
655
+ .log-line:first-child {
656
+ color: #89b4fa;
657
+ }
658
+
659
+ /* Dataset previews */
660
+ .dataset-previews {
661
+ display: flex;
662
+ flex-wrap: wrap;
663
+ gap: 0.5rem;
664
+ margin-top: 1rem;
665
+ }
666
+
667
+ .dataset-previews img {
668
+ width: 72px;
669
+ height: 72px;
670
+ object-fit: cover;
671
+ border-radius: var(--radius-sm);
672
+ background: #f3f4f6;
673
+ }
674
+
675
  @media (max-width: 640px) {
676
  .app {
677
  padding: 1.5rem 1rem 3rem;
 
688
  .context-input {
689
  flex-direction: column;
690
  }
691
+
692
+ .detail-panel {
693
+ grid-template-columns: 1fr;
694
+ grid-template-rows: auto auto;
695
+ max-height: 85vh;
696
+ }
697
+
698
+ .detail-image {
699
+ grid-column: 1;
700
+ grid-row: 1;
701
+ }
702
+
703
+ .detail-image img {
704
+ border-radius: var(--radius) var(--radius) 0 0;
705
+ min-height: 200px;
706
+ max-height: 220px;
707
+ }
708
+
709
+ .detail-info {
710
+ grid-column: 1;
711
+ grid-row: 2;
712
+ padding: 1rem;
713
+ }
714
  }