Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Wardrobe AI</title> | |
| <link rel="stylesheet" href="/style.css"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/dist/annotorious.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| import { createImageAnnotator } from "https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/+esm"; | |
| window.gradioClient = null; | |
| window.annotoriousFactory = createImageAnnotator; | |
| Client.connect(window.location.origin).then(c => { window.gradioClient = c; }); | |
| </script> | |
| </head> | |
| <body> | |
| <div class="app" x-data="wardrobe()" x-init="init()"> | |
| <header> | |
| <h1>Wardrobe AI</h1> | |
| <p>Your clothes, organized. Upload photos, get outfit ideas, ask anything.</p> | |
| </header> | |
| <nav> | |
| <button :class="{ active: tab === 'wardrobe' }" @click="tab = 'wardrobe'; loadWardrobe()">My Wardrobe</button> | |
| <button :class="{ active: tab === 'add' }" @click="tab = 'add'">Add Clothes</button> | |
| <button :class="{ active: tab === 'outfits' }" @click="tab = 'outfits'">Get Dressed</button> | |
| <button :class="{ active: tab === 'ask' }" @click="tab = 'ask'; loadWardrobe()">Ask</button> | |
| </nav> | |
| <!-- My Wardrobe --> | |
| <section x-show="tab === 'wardrobe'"> | |
| <h2>My Wardrobe <span class="count" x-text="garments.length ? `(${garments.length})` : ''"></span></h2> | |
| <div class="garment-grid" x-show="garments.length > 0"> | |
| <template x-for="g in garments" :key="g.id"> | |
| <div class="garment-card" @click="selectGarment(g)"> | |
| <img :src="g.image_url" :alt="g.type" loading="lazy"> | |
| <div class="info"> | |
| <div class="type" x-text="g.type || 'garment'"></div> | |
| <div class="color" x-text="g.color || ''"></div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="empty-state" x-show="garments.length === 0 && !loading"> | |
| <p>Your wardrobe is empty.</p> | |
| <button class="btn btn-primary" @click="tab = 'add'">Add your first clothes</button> | |
| </div> | |
| </section> | |
| <!-- Garment Detail Overlay --> | |
| <div class="detail-overlay" x-show="selectedGarment" @click.self="closeDetail()" @keydown.escape.window="closeDetail()"> | |
| <div class="detail-panel" x-show="selectedGarment"> | |
| <button class="detail-close" @click="closeDetail()" aria-label="Close">✕</button> | |
| <div class="detail-image"> | |
| <img :src="selectedGarment?.image_url" :alt="selectedGarment?.type" loading="lazy"> | |
| </div> | |
| <div class="detail-info"> | |
| <h3 class="detail-title" x-text="selectedGarment ? `${selectedGarment.color} ${selectedGarment.type}` : ''"></h3> | |
| <p class="detail-description" x-show="selectedGarment?.description" x-text="selectedGarment?.description"></p> | |
| <div class="attr-list"> | |
| <div class="attr-row"> | |
| <span class="attr-key">Type</span> | |
| <span class="attr-val" x-text="selectedGarment?.type || '—'"></span> | |
| </div> | |
| <div class="attr-row"> | |
| <span class="attr-key">Color</span> | |
| <span class="attr-val" x-text="selectedGarment?.color || '—'"></span> | |
| </div> | |
| <div class="attr-row"> | |
| <span class="attr-key">Material</span> | |
| <span class="attr-val" x-text="selectedGarment?.material || '—'"></span> | |
| </div> | |
| <div class="attr-row"> | |
| <span class="attr-key">Pattern</span> | |
| <span class="attr-val" x-text="selectedGarment?.pattern || '—'"></span> | |
| </div> | |
| <div class="attr-row"> | |
| <span class="attr-key">Season</span> | |
| <span class="attr-val" x-text="selectedGarment?.season || '—'"></span> | |
| </div> | |
| <div class="attr-row"> | |
| <span class="attr-key">Formality</span> | |
| <span class="attr-val" x-text="selectedGarment?.formality || '—'"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Clothes --> | |
| <section x-show="tab === 'add'"> | |
| <h2>Add Clothes</h2> | |
| <div class="upload-area" | |
| @click="$refs.fileInput.click()" | |
| @dragover.prevent="dragover = true" | |
| @dragleave="dragover = false" | |
| @drop.prevent="dragover = false; handleFile($event.dataTransfer.files[0])" | |
| :class="{ dragover }"> | |
| <div class="icon">+</div> | |
| <p>Tap to upload a photo of your clothes</p> | |
| <p style="font-size:0.8rem; margin-top:0.5rem; color:#9ca3af">or drag and drop an image here</p> | |
| <input type="file" accept="image/*" x-ref="fileInput" @change="handleFile($refs.fileInput.files[0])" style="display:none"> | |
| </div> | |
| <div class="status" :class="uploadStatus.type" x-show="uploadStatus.message" x-text="uploadStatus.message"></div> | |
| <!-- Bounding box editor (shown after upload) --> | |
| <div class="editor-area" x-show="editorActive"> | |
| <p class="editor-hint"> | |
| <strong x-text="editorAutoBoxCount > 0 ? `${editorAutoBoxCount} garment${editorAutoBoxCount > 1 ? 's' : ''} auto-detected.` : 'No garments auto-detected.'"></strong> | |
| Draw rectangles over any missing ones, adjust or delete existing boxes, then click Analyse. | |
| </p> | |
| <div class="editor-canvas-wrap"> | |
| <img id="annoImage" :src="editorImageUrl" alt="Garment photo"> | |
| </div> | |
| <div class="editor-actions"> | |
| <button class="btn btn-primary" @click="analyzeBoxes()" :disabled="analyzing || editorBoxCount === 0"> | |
| <span x-text="analyzing ? 'Analysing…' : `Analyse ${editorBoxCount} garment${editorBoxCount !== 1 ? 's' : ''}`"></span> | |
| </button> | |
| <button class="btn btn-secondary" @click="cancelEditor()" :disabled="analyzing">Cancel</button> | |
| </div> | |
| <div class="status" :class="analyzeStatus.type" x-show="analyzeStatus.message" x-text="analyzeStatus.message"></div> | |
| </div> | |
| <div style="margin-top:2rem"> | |
| <h2>Or load a sample dataset</h2> | |
| <div class="dataset-row"> | |
| <select x-model="datasetKey"> | |
| <option value="second-hand">Second-hand (individual garments)</option> | |
| <option value="fashion-1k">Fashion-1K (multi-garment, slower)</option> | |
| </select> | |
| <button class="btn btn-secondary" @click="loadDataset()" :disabled="datasetLoading"> | |
| <span x-text="datasetLoading ? 'Loading...' : 'Load Dataset'"></span> | |
| </button> | |
| </div> | |
| <div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div> | |
| </div> | |
| </section> | |
| <!-- Get Dressed --> | |
| <section x-show="tab === 'outfits'"> | |
| <h2>Get Dressed</h2> | |
| <div class="context-input"> | |
| <input type="text" x-model="context" placeholder="What's the occasion? (e.g. dinner with friends, casual Friday...)" @keydown.enter="generateOutfits()"> | |
| <button class="btn btn-primary" @click="generateOutfits()" :disabled="outfitsLoading"> | |
| <span x-text="outfitsLoading ? 'Generating...' : 'Generate'"></span> | |
| </button> | |
| </div> | |
| <div class="outfit-grid" x-show="outfits.length > 0"> | |
| <template x-for="combo in outfits" :key="combo.id"> | |
| <div class="outfit-card" :style="combo.rated ? 'opacity:0.4; transform:scale(0.97)' : ''"> | |
| <div class="outfit-images"> | |
| <img :src="combo.top.image_url" :alt="combo.top.type" loading="lazy"> | |
| <img :src="combo.bottom.image_url" :alt="combo.bottom.type" loading="lazy"> | |
| </div> | |
| <div class="outfit-info"> | |
| <span class="label" x-text="`${combo.top.color} ${combo.top.type} + ${combo.bottom.color} ${combo.bottom.type}`"></span> | |
| <div class="outfit-actions"> | |
| <button class="dislike" @click="rateOutfit(combo, false)">-</button> | |
| <button class="like" @click="rateOutfit(combo, true)">+</button> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="empty-state" x-show="outfits.length === 0 && !outfitsLoading"> | |
| <p>No combinations yet. Add at least one top and one bottom to your wardrobe, then hit Generate.</p> | |
| </div> | |
| </section> | |
| <!-- Ask --> | |
| <section x-show="tab === 'ask'"> | |
| <h2>Ask about your wardrobe</h2> | |
| <div class="chat-container"> | |
| <div class="chat-messages" x-ref="chatMessages"> | |
| <template x-for="(msg, i) in messages" :key="i"> | |
| <div class="chat-message" :class="msg.role"> | |
| <div class="bubble" x-html="msg.role === 'assistant' ? renderMessage(msg.text) : escapeHtml(msg.text)"></div> | |
| </div> | |
| </template> | |
| </div> | |
| <div class="chat-input"> | |
| <input type="text" x-model="question" placeholder="What should I wear for..." @keydown.enter="askQuestion()"> | |
| <button @click="askQuestion()" :disabled="asking">Send</button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Log Dock: fixed bottom panel shown during dataset loading --> | |
| <div class="log-dock" x-show="datasetLog.length > 0" :class="{ minimized: logMinimized }"> | |
| <div class="log-dock-header" @click="logMinimized = !logMinimized"> | |
| <span class="log-dock-title" x-text="datasetLoading ? `Loading dataset — ${datasetCount}/50` : `Done — ${datasetCount} garments added`"></span> | |
| <div class="log-dock-actions"> | |
| <button class="log-dock-btn" :title="logMinimized ? 'Expand' : 'Minimize'" @click.stop="logMinimized = !logMinimized" x-text="logMinimized ? '▲' : '▼'"></button> | |
| <button class="log-dock-btn" title="Close" @click.stop="datasetLog = []">✕</button> | |
| </div> | |
| </div> | |
| <div class="log-dock-body" x-show="!logMinimized"> | |
| <div class="log-console" x-ref="logConsole"> | |
| <template x-for="(line, i) in datasetLog" :key="i"> | |
| <div class="log-line" x-text="line"></div> | |
| </template> | |
| </div> | |
| <div class="dataset-previews" x-show="datasetPreviews.length > 0"> | |
| <template x-for="(url, i) in datasetPreviews" :key="i"> | |
| <img :src="url" :alt="`Garment ${i + 1}`" loading="lazy"> | |
| </template> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function wardrobe() { | |
| return { | |
| tab: 'wardrobe', | |
| loading: false, | |
| garments: [], | |
| garmentsById: {}, | |
| dragover: false, | |
| // Upload | |
| uploadStatus: { type: '', message: '' }, | |
| // Dataset | |
| datasetKey: 'second-hand', | |
| datasetLoading: false, | |
| datasetStatus: { type: '', message: '' }, | |
| datasetLog: [], | |
| datasetPreviews: [], | |
| datasetCount: 0, | |
| logMinimized: false, | |
| // Garment detail | |
| selectedGarment: null, | |
| // Bounding-box editor | |
| editorActive: false, | |
| editorImageUrl: '', | |
| editorToken: '', | |
| editorBoxCount: 0, | |
| editorAutoBoxCount: 0, | |
| analyzing: false, | |
| analyzeStatus: { type: '', message: '' }, | |
| anno: null, | |
| // Outfits | |
| context: '', | |
| outfits: [], | |
| outfitsLoading: false, | |
| // Chat | |
| messages: [{ role: 'assistant', text: 'Hi! Ask me anything about your wardrobe — outfit ideas, what to wear for an event, or care tips.' }], | |
| question: '', | |
| asking: false, | |
| async init() { | |
| await this.waitForClient(); | |
| await this.loadWardrobe(); | |
| }, | |
| async waitForClient() { | |
| while (!window.gradioClient) { | |
| await new Promise(r => setTimeout(r, 100)); | |
| } | |
| }, | |
| async loadWardrobe() { | |
| this.loading = true; | |
| try { | |
| const result = await window.gradioClient.predict("/get_wardrobe", {}); | |
| this.garments = result.data[0].garments || []; | |
| this.garmentsById = Object.fromEntries(this.garments.map(g => [g.id, g])); | |
| } catch (e) { console.error(e); } | |
| this.loading = false; | |
| }, | |
| async handleFile(file) { | |
| if (!file) return; | |
| this.uploadStatus = { type: 'loading', message: 'Detecting garments…' }; | |
| this.editorActive = false; | |
| this.analyzeStatus = { type: '', message: '' }; | |
| try { | |
| const { handle_file } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"); | |
| const result = await window.gradioClient.predict("/prepare_image", { image_path: handle_file(file) }); | |
| const data = result.data[0]; | |
| if (data.error) { | |
| this.uploadStatus = { type: 'error', message: data.error }; | |
| return; | |
| } | |
| this.uploadStatus = { type: '', message: '' }; | |
| this.editorToken = data.token; | |
| this.editorImageUrl = data.image_url; | |
| this.editorAutoBoxCount = data.boxes.length; | |
| this.editorBoxCount = data.boxes.length; | |
| this.editorActive = true; | |
| this.$nextTick(() => this.initAnnotator(data.boxes, data.width, data.height)); | |
| } catch (e) { | |
| this.uploadStatus = { type: 'error', message: 'Something went wrong. Please try again.' }; | |
| console.error(e); | |
| } | |
| }, | |
| async loadDataset() { | |
| this.datasetLoading = true; | |
| this.datasetLog = []; | |
| this.datasetPreviews = []; | |
| this.datasetCount = 0; | |
| this.logMinimized = false; | |
| this.datasetStatus = { type: 'loading', message: 'Starting...' }; | |
| try { | |
| const job = window.gradioClient.submit("/load_dataset", { dataset_key: this.datasetKey }); | |
| for await (const event of job) { | |
| if (event.type !== "data") continue; | |
| const data = event.data[0]; | |
| if (!data) continue; | |
| if (Array.isArray(data.log) && data.log.length) { | |
| this.datasetLog = data.log; | |
| this.$nextTick(() => { | |
| const el = this.$refs.logConsole; | |
| if (el) el.scrollTop = el.scrollHeight; | |
| }); | |
| } | |
| if (typeof data.count === "number") this.datasetCount = data.count; | |
| if (data.preview_url && !this.datasetPreviews.includes(data.preview_url)) { | |
| this.datasetPreviews.push(data.preview_url); | |
| } | |
| if (data.done) { | |
| if (data.error) { | |
| this.datasetStatus = { type: 'error', message: data.error }; | |
| } else { | |
| this.datasetStatus = { type: 'success', message: `Loaded ${data.count} garments into your wardrobe!` }; | |
| await this.loadWardrobe(); | |
| } | |
| break; | |
| } else { | |
| this.datasetStatus = { | |
| type: 'loading', | |
| message: data.count > 0 ? `Processing garments... ${data.count}/50` : 'Downloading dataset...', | |
| }; | |
| } | |
| } | |
| } catch (e) { | |
| this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' }; | |
| console.error(e); | |
| } | |
| this.datasetLoading = false; | |
| }, | |
| async generateOutfits() { | |
| this.outfitsLoading = true; | |
| this.outfits = []; | |
| try { | |
| const result = await window.gradioClient.predict("/get_combinations", { context: this.context }); | |
| const data = result.data[0]; | |
| this.outfits = (data.combinations || []).map(c => ({ ...c, rated: false })); | |
| } catch (e) { console.error(e); } | |
| this.outfitsLoading = false; | |
| }, | |
| async rateOutfit(combo, liked) { | |
| combo.rated = true; | |
| try { | |
| await window.gradioClient.predict("/rate_outfit", { | |
| top_id: combo.top.id, | |
| bottom_id: combo.bottom.id, | |
| liked, | |
| }); | |
| } catch (e) { console.error(e); } | |
| }, | |
| async askQuestion() { | |
| const q = this.question.trim(); | |
| if (!q) return; | |
| this.messages.push({ role: 'user', text: q }); | |
| this.question = ''; | |
| this.asking = true; | |
| this.$nextTick(() => { | |
| this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight; | |
| }); | |
| try { | |
| if (!this.garments.length) await this.loadWardrobe(); | |
| const result = await window.gradioClient.predict("/ask_question", { question: q }); | |
| this.messages.push({ role: 'assistant', text: result.data[0] }); | |
| } catch (e) { | |
| this.messages.push({ role: 'assistant', text: 'Sorry, something went wrong. Please try again.' }); | |
| console.error(e); | |
| } | |
| this.asking = false; | |
| this.$nextTick(() => { | |
| this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight; | |
| }); | |
| }, | |
| normalizeGarmentId(num) { | |
| const n = parseInt(num, 10); | |
| return Number.isNaN(n) ? `garment_${num}` : `garment_${String(n).padStart(3, '0')}`; | |
| }, | |
| buildGarmentChip(id) { | |
| const g = this.garmentsById[id]; | |
| if (!g) return `<span class="garment-ref">${id}</span>`; | |
| const label = g.description | |
| ? (g.description.length > 72 ? g.description.slice(0, 72) + '…' : g.description) | |
| : `${g.color} ${g.type}`; | |
| return `<span class="garment-inline"> | |
| <img src="${g.image_url}" alt="${this.escapeHtml(g.type)}"> | |
| <span>${this.escapeHtml(label)}</span> | |
| </span>`; | |
| }, | |
| renderMessage(text) { | |
| const chips = []; | |
| const toPlaceholder = (id) => { | |
| const idx = chips.length; | |
| chips.push(this.buildGarmentChip(id)); | |
| return `GARMENTCHIP${idx}END`; | |
| }; | |
| let raw = text.replace(/\[garment_(\d+)\]/gi, (_, num) => toPlaceholder(this.normalizeGarmentId(num))); | |
| raw = raw.replace(/\bgarment_(\d+)\b/gi, (_, num) => toPlaceholder(this.normalizeGarmentId(num))); | |
| let html = marked.parse(raw); | |
| chips.forEach((chip, i) => { | |
| html = html.replaceAll(`GARMENTCHIP${i}END`, chip); | |
| html = html.replaceAll(`<p>GARMENTCHIP${i}END</p>`, `<p>${chip}</p>`); | |
| }); | |
| return html; | |
| }, | |
| escapeHtml(str) { | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| }, | |
| initAnnotator(boxes, imgW, imgH) { | |
| // Destroy any previous instance | |
| if (this.anno) { | |
| try { this.anno.destroy(); } catch (_) {} | |
| this.anno = null; | |
| } | |
| const factory = window.annotoriousFactory; | |
| if (!factory) { | |
| console.error('Annotorious not loaded yet'); | |
| return; | |
| } | |
| const el = document.getElementById('annoImage'); | |
| if (!el) return; | |
| const start = () => { | |
| this.anno = factory(el, { | |
| drawingEnabled: true, | |
| style: { fill: '#3b82f6', fillOpacity: 0.15, stroke: '#3b82f6', strokeWidth: 2 }, | |
| }); | |
| this.anno.setDrawingTool('rectangle'); | |
| // Load auto-detected boxes using Annotorious v3 internal format | |
| if (boxes.length > 0) { | |
| const annotations = boxes.map((b, i) => ({ | |
| id: `auto-${i}`, | |
| bodies: [], | |
| target: { | |
| selector: { | |
| type: 'RECTANGLE', | |
| geometry: { | |
| x: b.x, y: b.y, w: b.w, h: b.h, | |
| bounds: { minX: b.x, minY: b.y, maxX: b.x + b.w, maxY: b.y + b.h }, | |
| }, | |
| }, | |
| }, | |
| })); | |
| this.anno.setAnnotations(annotations); | |
| } | |
| // Keep editorBoxCount in sync | |
| const updateCount = () => { | |
| this.editorBoxCount = this.anno.getAnnotations().length; | |
| }; | |
| this.anno.on('createAnnotation', updateCount); | |
| this.anno.on('deleteAnnotation', updateCount); | |
| this.anno.on('updateAnnotation', updateCount); | |
| }; | |
| // Wait for the image to be loaded before creating the annotator | |
| if (el.complete && el.naturalWidth > 0) { | |
| start(); | |
| } else { | |
| el.addEventListener('load', start, { once: true }); | |
| } | |
| }, | |
| async analyzeBoxes() { | |
| if (!this.anno || this.editorBoxCount === 0) return; | |
| this.analyzing = true; | |
| this.analyzeStatus = { type: 'loading', message: 'Extracting garment attributes…' }; | |
| // Map Annotorious v3 RECTANGLE geometry to {x,y,w,h} pixel boxes | |
| const annotations = this.anno.getAnnotations(); | |
| const boxes = annotations.map(ann => { | |
| const geom = ann.target?.selector?.geometry; | |
| if (geom && geom.w > 0 && geom.h > 0) { | |
| return { x: Math.round(geom.x), y: Math.round(geom.y), w: Math.round(geom.w), h: Math.round(geom.h) }; | |
| } | |
| return null; | |
| }).filter(Boolean); | |
| if (boxes.length === 0) { | |
| this.analyzeStatus = { type: 'error', message: 'No valid boxes to analyze.' }; | |
| this.analyzing = false; | |
| return; | |
| } | |
| try { | |
| const result = await window.gradioClient.predict("/analyze_boxes", { | |
| token: this.editorToken, | |
| boxes: JSON.stringify(boxes), | |
| }); | |
| const data = result.data[0]; | |
| if (data.error) { | |
| this.analyzeStatus = { type: 'error', message: data.error }; | |
| } else { | |
| this.analyzeStatus = { type: 'success', message: `Added ${data.count} garment${data.count !== 1 ? 's' : ''} to your wardrobe!` }; | |
| await this.loadWardrobe(); | |
| this.cancelEditor(); | |
| } | |
| } catch (e) { | |
| this.analyzeStatus = { type: 'error', message: 'Something went wrong. Please try again.' }; | |
| console.error(e); | |
| } | |
| this.analyzing = false; | |
| }, | |
| cancelEditor() { | |
| if (this.anno) { | |
| try { this.anno.destroy(); } catch (_) {} | |
| this.anno = null; | |
| } | |
| this.editorActive = false; | |
| this.editorToken = ''; | |
| this.editorImageUrl = ''; | |
| this.editorBoxCount = 0; | |
| this.editorAutoBoxCount = 0; | |
| }, | |
| selectGarment(g) { | |
| this.selectedGarment = g; | |
| }, | |
| closeDetail() { | |
| this.selectedGarment = null; | |
| }, | |
| }; | |
| } | |
| </script> | |
| </body> | |
| </html> | |