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

fix(ui): log dock, garment detail layout and image cache

Browse files

- Restore `-> dict` return annotation on api_load_dataset so Gradio
serialises each generator yield as an SSE data event (logs were
silently dropped without it).
- Move log console to a fixed bottom dock (.log-dock) with a header
showing live progress, a minimise/expand toggle and a close button;
dock is visible from any tab during dataset loading.
- Redesign garment detail panel as a single column with
object-fit:contain image (no more distorted/cropped previews) and
a clean attribute list below.
- Add _image_url() helper with ?v=<mtime> query param to bust browser
cache when the same garment ID is reused after clearing the catalog.
- Call loadWardrobe() after a successful photo upload so the new
garment appears immediately without a manual refresh.

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

Files changed (3) hide show
  1. app.py +22 -7
  2. src/ui/index.html +26 -12
  3. src/ui/style.css +82 -44
app.py CHANGED
@@ -813,11 +813,23 @@ def _build_custom_server():
813
 
814
  server.mount("/garments", StaticFiles(directory=str(_DATA_DIR / "garments")), name="garments")
815
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  @server.api(name="get_wardrobe")
817
  def api_get_wardrobe() -> dict:
818
  catalog = load_catalog()
819
  for g in catalog:
820
- g["image_url"] = f"/garments/{g.get('id', '')}.jpg"
821
  return {"garments": catalog, "count": len(catalog)}
822
 
823
  @server.api(name="add_photo")
@@ -829,7 +841,7 @@ def _build_custom_server():
829
  return {"garments": [], "count": 0}
830
  added = add_garments(results)
831
  for g in added:
832
- g["image_url"] = f"/garments/{g['id']}.jpg"
833
  return {"garments": added, "count": len(added)}
834
 
835
  @server.api(name="get_combinations")
@@ -845,9 +857,9 @@ def _build_custom_server():
845
  serialized.append({
846
  "id": combo["id"],
847
  "top": {"id": top.get("id", ""), "type": top.get("type", ""),
848
- "color": top.get("color", ""), "image_url": f"/garments/{top.get('id', '')}.jpg"},
849
  "bottom": {"id": bottom.get("id", ""), "type": bottom.get("type", ""),
850
- "color": bottom.get("color", ""), "image_url": f"/garments/{bottom.get('id', '')}.jpg"},
851
  })
852
  return {"combinations": serialized, "count": len(serialized)}
853
 
@@ -863,14 +875,17 @@ def _build_custom_server():
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 = {
875
  "second-hand": {"hf_id": "fnauman/fashion-second-hand-front-only-rgb", "needs_detection": False},
876
  "fashion-1k": {"hf_id": "Codatta/Fashion-1K", "needs_detection": True},
@@ -929,7 +944,7 @@ def _build_custom_server():
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:
@@ -943,7 +958,7 @@ def _build_custom_server():
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
 
 
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)
819
+ if img_path:
820
+ try:
821
+ v = int(os.path.getmtime(img_path))
822
+ except OSError:
823
+ v = 0
824
+ else:
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()
831
  for g in catalog:
832
+ g["image_url"] = _image_url(g.get("id", ""))
833
  return {"garments": catalog, "count": len(catalog)}
834
 
835
  @server.api(name="add_photo")
 
841
  return {"garments": [], "count": 0}
842
  added = add_garments(results)
843
  for g in added:
844
+ g["image_url"] = _image_url(g["id"])
845
  return {"garments": added, "count": len(added)}
846
 
847
  @server.api(name="get_combinations")
 
857
  serialized.append({
858
  "id": combo["id"],
859
  "top": {"id": top.get("id", ""), "type": top.get("type", ""),
860
+ "color": top.get("color", ""), "image_url": _image_url(top.get("id", ""))},
861
  "bottom": {"id": bottom.get("id", ""), "type": bottom.get("type", ""),
862
+ "color": bottom.get("color", ""), "image_url": _image_url(bottom.get("id", ""))},
863
  })
864
  return {"combinations": serialized, "count": len(serialized)}
865
 
 
875
  return ask(question.strip())
876
 
877
  @server.api(name="load_dataset")
878
+ def api_load_dataset(dataset_key: str) -> dict:
879
  """Stream dataset processing progress as each garment is analyzed.
880
 
881
  Yields progress dicts with: done, count, log[], preview_url.
882
  The final yield has done=True with the total count.
883
  """
884
+ import time as _time
885
  from datasets import load_dataset as hf_load
886
 
887
+ _load_ts = int(_time.time())
888
+
889
  ds_configs = {
890
  "second-hand": {"hf_id": "fnauman/fashion-second-hand-front-only-rgb", "needs_detection": False},
891
  "fashion-1k": {"hf_id": "Codatta/Fashion-1K", "needs_detection": True},
 
944
  garments_processed.append((garment, crop_bytes))
945
  n = len(garments_processed)
946
  preview_path = _save_temp_preview(crop_bytes, n)
947
+ preview_url = f"/garments/_preview_{n:03d}.jpg?v={_load_ts}" if preview_path else None
948
  log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
949
  yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
950
  else:
 
958
  garments_processed.append((garment, crop_bytes))
959
  n = len(garments_processed)
960
  preview_path = _save_temp_preview(crop_bytes, n)
961
+ preview_url = f"/garments/_preview_{n:03d}.jpg?v={_load_ts}" if preview_path else None
962
  log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
963
  yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
964
 
src/ui/index.html CHANGED
@@ -119,18 +119,6 @@
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
 
@@ -184,6 +172,29 @@
184
  </div>
185
  </div>
186
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  </div>
188
 
189
  <script>
@@ -204,6 +215,7 @@
204
  datasetLog: [],
205
  datasetPreviews: [],
206
  datasetCount: 0,
 
207
 
208
  // Garment detail
209
  selectedGarment: null,
@@ -247,6 +259,7 @@
247
  const data = result.data[0];
248
  if (data.count > 0) {
249
  this.uploadStatus = { type: 'success', message: `Found ${data.count} garment${data.count > 1 ? 's' : ''}! Check your wardrobe.` };
 
250
  } else {
251
  this.uploadStatus = { type: 'error', message: "Couldn't detect any garments. Try a clearer image." };
252
  }
@@ -261,6 +274,7 @@
261
  this.datasetLog = [];
262
  this.datasetPreviews = [];
263
  this.datasetCount = 0;
 
264
  this.datasetStatus = { type: 'loading', message: 'Starting...' };
265
 
266
  try {
 
119
  </button>
120
  </div>
121
  <div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
 
 
 
 
 
 
 
 
 
 
 
 
122
  </div>
123
  </section>
124
 
 
172
  </div>
173
  </div>
174
  </section>
175
+
176
+ <!-- Log Dock: fixed bottom panel shown during dataset loading -->
177
+ <div class="log-dock" x-show="datasetLog.length > 0" :class="{ minimized: logMinimized }">
178
+ <div class="log-dock-header" @click="logMinimized = !logMinimized">
179
+ <span class="log-dock-title" x-text="datasetLoading ? `Loading dataset — ${datasetCount}/50` : `Done — ${datasetCount} garments added`"></span>
180
+ <div class="log-dock-actions">
181
+ <button class="log-dock-btn" :title="logMinimized ? 'Expand' : 'Minimize'" @click.stop="logMinimized = !logMinimized" x-text="logMinimized ? '▲' : '▼'"></button>
182
+ <button class="log-dock-btn" title="Close" @click.stop="datasetLog = []">✕</button>
183
+ </div>
184
+ </div>
185
+ <div class="log-dock-body" x-show="!logMinimized">
186
+ <div class="log-console" x-ref="logConsole">
187
+ <template x-for="(line, i) in datasetLog" :key="i">
188
+ <div class="log-line" x-text="line"></div>
189
+ </template>
190
+ </div>
191
+ <div class="dataset-previews" x-show="datasetPreviews.length > 0">
192
+ <template x-for="(url, i) in datasetPreviews" :key="i">
193
+ <img :src="url" :alt="`Garment ${i + 1}`" loading="lazy">
194
+ </template>
195
+ </div>
196
+ </div>
197
+ </div>
198
  </div>
199
 
200
  <script>
 
215
  datasetLog: [],
216
  datasetPreviews: [],
217
  datasetCount: 0,
218
+ logMinimized: false,
219
 
220
  // Garment detail
221
  selectedGarment: null,
 
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
  }
 
274
  this.datasetLog = [];
275
  this.datasetPreviews = [];
276
  this.datasetCount = 0;
277
+ this.logMinimized = false;
278
  this.datasetStatus = { type: 'loading', message: 'Starting...' };
279
 
280
  try {
src/ui/style.css CHANGED
@@ -529,21 +529,20 @@ nav button.active {
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;
@@ -564,54 +563,50 @@ nav button.active {
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
 
@@ -623,7 +618,7 @@ nav button.active {
623
  color: var(--text-muted);
624
  font-weight: 500;
625
  flex-shrink: 0;
626
- margin-right: 0.75rem;
627
  }
628
 
629
  .attr-val {
@@ -632,21 +627,75 @@ nav button.active {
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;
@@ -690,25 +739,14 @@ nav button.active {
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
  }
 
529
  background: var(--surface);
530
  border-radius: var(--radius);
531
  box-shadow: var(--shadow-lg);
532
+ max-width: 420px;
533
  width: 100%;
534
  max-height: 90vh;
535
  overflow-y: auto;
 
 
 
536
  position: relative;
537
+ display: flex;
538
+ flex-direction: column;
539
  }
540
 
541
  .detail-close {
542
  position: absolute;
543
  top: 0.75rem;
544
  right: 0.75rem;
545
+ background: rgba(255, 255, 255, 0.85);
546
  border: 1px solid var(--border);
547
  border-radius: 50%;
548
  width: 32px;
 
563
  }
564
 
565
  .detail-image {
566
+ width: 100%;
567
+ background: #f3f4f6;
568
+ border-radius: var(--radius) var(--radius) 0 0;
569
+ overflow: hidden;
570
+ flex-shrink: 0;
571
  }
572
 
573
  .detail-image img {
574
  width: 100%;
575
+ max-height: 320px;
576
+ object-fit: contain;
 
 
577
  display: block;
 
578
  }
579
 
580
  .detail-info {
581
+ padding: 1.25rem 1.5rem 1.5rem;
 
 
582
  }
583
 
584
  .detail-title {
585
+ font-size: 1.05rem;
586
  font-weight: 600;
587
  text-transform: capitalize;
588
  margin-bottom: 0.5rem;
 
589
  }
590
 
591
  .detail-description {
592
+ font-size: 0.85rem;
593
  color: var(--text-muted);
594
+ line-height: 1.55;
595
+ margin-bottom: 1.1rem;
596
  font-style: italic;
597
  }
598
 
599
  .attr-list {
600
  display: flex;
601
  flex-direction: column;
 
602
  }
603
 
604
  .attr-row {
605
  display: flex;
606
  justify-content: space-between;
607
  align-items: baseline;
608
+ font-size: 0.85rem;
609
+ padding: 0.45rem 0;
610
  border-bottom: 1px solid var(--border);
611
  }
612
 
 
618
  color: var(--text-muted);
619
  font-weight: 500;
620
  flex-shrink: 0;
621
+ margin-right: 1rem;
622
  }
623
 
624
  .attr-val {
 
627
  font-weight: 500;
628
  }
629
 
630
+ /* Log dock — fixed bottom panel */
631
+ .log-dock {
632
+ position: fixed;
633
+ bottom: 0;
634
+ right: 1.5rem;
635
+ width: min(440px, calc(100vw - 3rem));
636
+ z-index: 200;
637
+ border-radius: var(--radius) var(--radius) 0 0;
638
+ overflow: hidden;
639
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.18);
640
  background: #1e1e2e;
 
 
 
 
 
641
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
642
+ }
643
+
644
+ .log-dock-header {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: space-between;
648
+ padding: 0.55rem 0.85rem;
649
+ background: #2a2a3e;
650
+ cursor: pointer;
651
+ user-select: none;
652
+ gap: 0.5rem;
653
+ }
654
+
655
+ .log-dock-title {
656
  font-size: 0.78rem;
657
+ color: #89b4fa;
658
+ font-weight: 500;
659
+ flex: 1;
660
+ overflow: hidden;
661
+ text-overflow: ellipsis;
662
+ white-space: nowrap;
663
+ }
664
+
665
+ .log-dock-actions {
666
+ display: flex;
667
+ gap: 0.35rem;
668
+ flex-shrink: 0;
669
+ }
670
+
671
+ .log-dock-btn {
672
+ background: none;
673
+ border: none;
674
+ color: #6b7280;
675
+ cursor: pointer;
676
+ font-size: 0.75rem;
677
+ padding: 2px 6px;
678
+ border-radius: 4px;
679
+ transition: color var(--transition);
680
+ }
681
+
682
+ .log-dock-btn:hover {
683
+ color: #e5e7eb;
684
+ }
685
+
686
+ .log-dock-body {
687
+ padding: 0.6rem 0.85rem 0.75rem;
688
+ }
689
+
690
+ .log-console {
691
+ max-height: 160px;
692
+ overflow-y: auto;
693
  scroll-behavior: smooth;
694
  }
695
 
696
  .log-line {
697
+ font-size: 0.78rem;
698
+ line-height: 1.65;
699
  color: #a6e3a1;
700
  white-space: pre-wrap;
701
  word-break: break-all;
 
739
  }
740
 
741
  .detail-panel {
742
+ max-height: 88vh;
 
 
 
 
 
 
 
743
  }
744
 
745
  .detail-image img {
 
 
746
  max-height: 220px;
747
  }
748
 
749
  .detail-info {
 
 
750
  padding: 1rem;
751
  }
752
  }