Spaces:
Runtime error
Runtime error
| """Wardrobe AI — Turn your wardrobe into a searchable knowledge graph. | |
| A Gradio app that uses Gemma 3 4B (GGUF) to extract garment attributes | |
| from photos and answer natural language questions about your clothes. | |
| Built for the Build Small Hackathon (HuggingFace x Gradio, June 2026). | |
| """ | |
| import io | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| load_dotenv(Path(__file__).resolve().parent / ".env") | |
| import gradio as gr | |
| from PIL import Image | |
| from src.vision import extract_garments, extract_single_from_path, extract_from_crop_bytes, _extract_single_garment | |
| from src.catalog import ( | |
| add_garments, | |
| load_catalog, | |
| clear_catalog, | |
| get_catalog_summary, | |
| get_catalog_stats, | |
| get_garment_image_path, | |
| ) | |
| from src.assistant import ask_streaming | |
| from src.combinations import ( | |
| generate_combinations, | |
| rank_with_llm, | |
| save_preference, | |
| get_liked_outfits, | |
| ) | |
| from src.detector import detect_garments as detect_boxes, crop_garments, list_available, BoundingBox | |
| from src import settings | |
| from gradio_image_annotation import image_annotator | |
| if os.environ.get("SPACE_ID"): | |
| os.environ["CUDA_VISIBLE_DEVICES"] = "" | |
| else: | |
| os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0") | |
| logging.basicConfig(level=logging.INFO, format="%(name)s | %(message)s") | |
| _DATA_DIR = Path(__file__).resolve().parent / "data" | |
| _DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| (_DATA_DIR / "garments").mkdir(parents=True, exist_ok=True) | |
| _startup_catalog = load_catalog() | |
| logging.info("Startup: catalog loaded with %d garments", len(_startup_catalog)) | |
| del _startup_catalog | |
| HEADER = """ | |
| # 👕 Wardrobe AI | |
| **Mi madre tiene más de 200 prendas.** Cada mañana pierde tiempo buscando | |
| algo que combine. No recuerda qué ropa tiene. Compra ropa duplicada. | |
| Wardrobe AI transforma un armario físico en un catálogo consultable | |
| mediante lenguaje natural. Sube fotos → detecta prendas → pregunta lo que quieras. | |
| <small>Gemma 3 4B · 100% local · sin APIs externas · llama.cpp</small> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Capture tab handlers | |
| # --------------------------------------------------------------------------- | |
| def _format_results(added: list[dict]) -> tuple[str, list]: | |
| """Format detection results as (status_markdown, table_data).""" | |
| if not added: | |
| return "No se detectaron prendas.", [] | |
| table_data = [ | |
| [g["id"], g["type"], g["color"], g["material"], g["pattern"], g["season"], g["formality"]] | |
| for g in added | |
| ] | |
| status = f"Se detectaron **{len(added)}** prendas nuevas y se añadieron al catálogo." | |
| return status, table_data | |
| def auto_detect(annotation, mode, progress=gr.Progress(track_tqdm=False)): | |
| """Run automatic detection on the uploaded image. | |
| Uses the configured detection backend to find garments, crops them, | |
| and sends each crop to the VLM for attribute extraction. | |
| """ | |
| if not annotation or not annotation.get("image"): | |
| return "Sube una imagen primero.", [] | |
| image_path = annotation["image"] | |
| if not isinstance(image_path, str): | |
| return "Error: imagen no válida.", [] | |
| progress((0, 1), desc="Detectando prendas...") | |
| try: | |
| if mode == "single": | |
| garments_with_crops = extract_single_from_path(image_path) | |
| else: | |
| garments_with_crops = extract_garments(image_path) | |
| except Exception as e: | |
| logging.error("Error in auto detection: %s", e) | |
| return f"Error durante la detección: {e}", [] | |
| progress((1, 1), desc="Completado") | |
| if not garments_with_crops: | |
| return ( | |
| "**No se detectaron prendas automáticamente.** " | |
| "Prueba a dibujar rectángulos sobre las prendas y pulsa 'Procesar selección manual'.", | |
| [], | |
| ) | |
| added = add_garments(garments_with_crops) | |
| return _format_results(added) | |
| def process_manual_selection(annotation, progress=gr.Progress(track_tqdm=False)): | |
| """Process manually drawn bounding boxes from the annotator. | |
| User draws rectangles over garments, we crop each one and send to VLM. | |
| """ | |
| if not annotation or "boxes" not in annotation or not annotation["boxes"]: | |
| return "Dibuja al menos un rectángulo sobre una prenda.", [] | |
| image = annotation["image"] | |
| boxes = annotation["boxes"] | |
| if isinstance(image, str): | |
| img = Image.open(image).convert("RGB") | |
| else: | |
| img = Image.fromarray(image).convert("RGB") | |
| total = len(boxes) | |
| all_results = [] | |
| for idx, box in enumerate(boxes): | |
| progress((idx, total), desc=f"Analizando recorte {idx + 1}/{total}...") | |
| x1 = int(box["xmin"]) | |
| y1 = int(box["ymin"]) | |
| x2 = int(box["xmax"]) | |
| y2 = int(box["ymax"]) | |
| cropped = img.crop((x1, y1, x2, y2)) | |
| cropped.thumbnail((512, 512), Image.LANCZOS) | |
| buffer = io.BytesIO() | |
| cropped.save(buffer, format="JPEG", quality=85) | |
| crop_bytes = buffer.getvalue() | |
| result = extract_from_crop_bytes(crop_bytes) | |
| if result: | |
| all_results.append(result) | |
| progress((total, total), desc="Completado") | |
| if not all_results: | |
| return "No se pudieron analizar las prendas seleccionadas.", [] | |
| added = add_garments(all_results) | |
| return _format_results(added) | |
| # --------------------------------------------------------------------------- | |
| # Settings handlers | |
| # --------------------------------------------------------------------------- | |
| def update_detection_backend(backend_name: str): | |
| """Persist detection backend change.""" | |
| settings.update("detection_backend", backend_name) | |
| return f"Backend actualizado: **{backend_name}**" | |
| # --------------------------------------------------------------------------- | |
| # Dataset loading handler (in-process with real-time progress) | |
| # --------------------------------------------------------------------------- | |
| DATA_DIR = Path(__file__).resolve().parent / "data" | |
| SAMPLE_DATASETS = [ | |
| ("second-hand", "Second-hand (prendas individuales)"), | |
| ("fashion-1k", "Fashion-1K (multi-garment, requiere detección)"), | |
| ] | |
| TARGET_GARMENTS = 50 | |
| def obtener_dataset(dataset_key: str): | |
| """Download a HF dataset and process garments in-process with real-time UI updates. | |
| Yields (log_markdown, gallery_images) at each step. | |
| """ | |
| from datasets import load_dataset | |
| ds_configs = { | |
| "second-hand": { | |
| "hf_id": "fnauman/fashion-second-hand-front-only-rgb", | |
| "needs_detection": False, | |
| }, | |
| "fashion-1k": { | |
| "hf_id": "Codatta/Fashion-1K", | |
| "needs_detection": True, | |
| }, | |
| } | |
| config = ds_configs.get(dataset_key) | |
| if not config: | |
| yield "Error: dataset no reconocido.", [] | |
| return | |
| yield f"Descargando dataset **{config['hf_id']}**...", [] | |
| try: | |
| ds = load_dataset(config["hf_id"], split="train") | |
| except Exception as e: | |
| yield f"Error descargando dataset: {e}", [] | |
| return | |
| yield f"Dataset cargado: **{len(ds)}** imágenes. Iniciando procesamiento...", [] | |
| # Spread indices for variety | |
| step = max(1, len(ds) // (TARGET_GARMENTS * 2)) | |
| indices = list(range(0, len(ds), step))[:TARGET_GARMENTS * 3] | |
| gallery_images: list[str] = [] | |
| garments_processed: list[tuple[dict, bytes]] = [] | |
| log_lines: list[str] = [] | |
| for idx in indices: | |
| if len(garments_processed) >= TARGET_GARMENTS: | |
| break | |
| if idx >= len(ds): | |
| continue | |
| sample = ds[idx] | |
| image = sample.get("image") or sample.get("img") | |
| if not isinstance(image, Image.Image): | |
| continue | |
| if image.mode != "RGB": | |
| image = image.convert("RGB") | |
| if config["needs_detection"]: | |
| import tempfile | |
| with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: | |
| image.save(tmp, format="JPEG", quality=90) | |
| tmp_path = tmp.name | |
| try: | |
| from src.detector import detect_and_crop | |
| crops = detect_and_crop(tmp_path) | |
| except Exception: | |
| crops = [] | |
| finally: | |
| Path(tmp_path).unlink(missing_ok=True) | |
| if not crops: | |
| continue | |
| for crop_bytes in crops: | |
| if len(garments_processed) >= TARGET_GARMENTS: | |
| break | |
| garment = _extract_single_garment(crop_bytes) | |
| if garment: | |
| garments_processed.append((garment, crop_bytes)) | |
| img_path = _save_temp_preview(crop_bytes, len(garments_processed)) | |
| if img_path: | |
| gallery_images.append(img_path) | |
| n = len(garments_processed) | |
| log_lines.append( | |
| f"**{n}/{TARGET_GARMENTS}** — {garment.get('color', '?')} {garment.get('type', '?')}" | |
| ) | |
| yield "\n".join(log_lines[-8:]), gallery_images | |
| else: | |
| if max(image.size) > 512: | |
| image.thumbnail((512, 512), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| image.save(buf, format="JPEG", quality=90) | |
| crop_bytes = buf.getvalue() | |
| garment = _extract_single_garment(crop_bytes) | |
| if not garment: | |
| continue | |
| garments_processed.append((garment, crop_bytes)) | |
| img_path = _save_temp_preview(crop_bytes, len(garments_processed)) | |
| if img_path: | |
| gallery_images.append(img_path) | |
| n = len(garments_processed) | |
| log_lines.append( | |
| f"**{n}/{TARGET_GARMENTS}** — {garment.get('color', '?')} {garment.get('type', '?')}" | |
| ) | |
| yield "\n".join(log_lines[-8:]), gallery_images | |
| if not garments_processed: | |
| yield "No se pudieron extraer prendas del dataset.", gallery_images | |
| return | |
| # Save to catalog | |
| added = add_garments(garments_processed) | |
| final_log = "\n".join(log_lines[-5:]) + ( | |
| f"\n\n---\n**Completado: {len(added)} prendas** añadidas al armario. " | |
| f"Ve a 'Mi Armario' para explorarlas." | |
| ) | |
| yield final_log, gallery_images | |
| def _save_temp_preview(crop_bytes: bytes, index: int) -> str | None: | |
| """Save a preview image for the gallery during dataset loading.""" | |
| preview_dir = DATA_DIR / "garments" | |
| preview_dir.mkdir(parents=True, exist_ok=True) | |
| path = preview_dir / f"_preview_{index:03d}.jpg" | |
| try: | |
| path.write_bytes(crop_bytes) | |
| return str(path) | |
| except Exception: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Wardrobe tab handlers | |
| # --------------------------------------------------------------------------- | |
| def _get_catalog_choices() -> list[tuple[str, str]]: | |
| """Build (label, garment_id) pairs for the garment list.""" | |
| catalog = load_catalog() | |
| choices = [] | |
| for g in catalog: | |
| gid = g.get("id", "?") | |
| desc = g.get("description", "") | |
| if desc: | |
| label = desc[:80] | |
| else: | |
| color = g.get("color", "?").title() | |
| gtype = g.get("type", "?") | |
| pattern = g.get("pattern", "solid") | |
| label = f"{color} {gtype} ({pattern})" | |
| choices.append((label, gid)) | |
| return choices | |
| def _format_garment_detail(garment_id: str) -> tuple[str | None, str]: | |
| """Return (image_path, detail_markdown) for a garment.""" | |
| catalog = load_catalog() | |
| garment = next((g for g in catalog if g.get("id") == garment_id), None) | |
| if not garment: | |
| return None, "Selecciona una prenda de la lista." | |
| img_path = get_garment_image_path(garment_id) | |
| lines = [f"### {garment.get('color', '?').title()} {garment.get('type', '?').title()}"] | |
| desc = garment.get("description", "") | |
| if desc: | |
| lines.append(f"\n*{desc}*\n") | |
| if not img_path: | |
| lines.append("\n*Sin imagen disponible*\n") | |
| lines.append("\n| Atributo | Valor |") | |
| lines.append("|----------|-------|") | |
| lines.append(f"| **ID** | `{garment.get('id', '?')}` |") | |
| lines.append(f"| **Tipo** | {garment.get('type', '?')} |") | |
| lines.append(f"| **Color** | {garment.get('color', '?')} |") | |
| lines.append(f"| **Material** | {garment.get('material', '?')} |") | |
| lines.append(f"| **Patrón** | {garment.get('pattern', '?')} |") | |
| lines.append(f"| **Temporada** | {garment.get('season', '?')} |") | |
| lines.append(f"| **Estilo** | {garment.get('formality', '?')} |") | |
| return img_path, "\n".join(lines) | |
| def _format_catalog_stats() -> str: | |
| """Format aggregate stats for display above the list.""" | |
| stats = get_catalog_stats() | |
| if stats["total"] == 0: | |
| return "El catálogo está vacío. Sube fotos de tu ropa para empezar." | |
| lines = [f"**{stats['total']} prendas** en tu armario"] | |
| if stats.get("by_type"): | |
| type_summary = ", ".join(f"{v}x {k}" for k, v in list(stats["by_type"].items())[:6]) | |
| lines.append(f"Tipos: {type_summary}") | |
| return " · ".join(lines) | |
| def select_garment(garment_id: str): | |
| """Handle garment selection from the list.""" | |
| if not garment_id: | |
| return gr.update(value=None), "Selecciona una prenda de la lista." | |
| img_path, detail = _format_garment_detail(garment_id) | |
| return gr.update(value=img_path), detail | |
| def refresh_catalog(): | |
| """Refresh the catalog list and stats.""" | |
| choices = _get_catalog_choices() | |
| stats = _format_catalog_stats() | |
| return ( | |
| gr.update(choices=choices, value=None), | |
| stats, | |
| gr.update(value=None), | |
| "Selecciona una prenda de la lista.", | |
| ) | |
| def clear_all(): | |
| """Clear the entire catalog.""" | |
| clear_catalog() | |
| return ( | |
| gr.update(choices=[], value=None), | |
| "Catálogo vaciado.", | |
| gr.update(value=None), | |
| "El catálogo está vacío.", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Chat handlers | |
| # --------------------------------------------------------------------------- | |
| def chat_respond(message, history): | |
| """Handle chat messages with streaming responses.""" | |
| if not message: | |
| return "" | |
| partial = "" | |
| for token in ask_streaming(message): | |
| partial += token | |
| yield partial | |
| EXAMPLE_QUESTIONS = [ | |
| "¿Qué me pongo para una cena informal?", | |
| "¿Qué combinaciones puedo hacer con los jeans?", | |
| "¿Cómo debo lavar el sweater de lana?", | |
| "¿Qué ropa tengo para otoño?", | |
| "¿Qué me pongo si hace 32 grados?", | |
| "What should I wear for a job interview?", | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Combinations tab handlers | |
| # --------------------------------------------------------------------------- | |
| def _get_combo_display(combo: dict | None) -> tuple[str | None, str | None, str, str]: | |
| """Extract display data from a combination.""" | |
| if not combo: | |
| return None, None, "No hay combinaciones disponibles.", "" | |
| top = combo["top"] | |
| bottom = combo["bottom"] | |
| top_img = get_garment_image_path(top["id"]) | |
| bottom_img = get_garment_image_path(bottom["id"]) | |
| top_text = top.get("description") or f"{top['color'].title()} {top['type']}" | |
| bottom_text = bottom.get("description") or f"{bottom['color'].title()} {bottom['type']}" | |
| return top_img, bottom_img, top_text, bottom_text | |
| def init_combinations(state, context): | |
| """Initialize or refresh the combination queue, optionally ranked by context.""" | |
| combos = generate_combinations() | |
| if not combos: | |
| return ( | |
| state, | |
| None, None, | |
| "No hay suficientes prendas (necesitas al menos 1 top y 1 bottom).", | |
| "", | |
| "0 combinaciones", | |
| ) | |
| combos = rank_with_llm(combos, context.strip() if context else "") | |
| state = {"queue": combos, "index": 0} | |
| combo = combos[0] | |
| top_img, bottom_img, top_text, bottom_text = _get_combo_display(combo) | |
| progress = f"1 / {len(combos)}" | |
| return state, top_img, bottom_img, top_text, bottom_text, progress | |
| def handle_preference(liked: bool, state): | |
| """Record preference and advance to the next combination.""" | |
| if not state or "queue" not in state: | |
| return state, None, None, "Genera combinaciones primero.", "", "—" | |
| queue = state["queue"] | |
| idx = state["index"] | |
| if idx < len(queue): | |
| combo = queue[idx] | |
| save_preference(combo["top"]["id"], combo["bottom"]["id"], liked) | |
| idx += 1 | |
| state["index"] = idx | |
| if idx >= len(queue): | |
| return ( | |
| state, None, None, | |
| "Has revisado todas las combinaciones.", | |
| "Pulsa 'Generar' para crear nuevas.", | |
| f"{idx} / {len(queue)}", | |
| ) | |
| combo = queue[idx] | |
| top_img, bottom_img, top_text, bottom_text = _get_combo_display(combo) | |
| progress = f"{idx + 1} / {len(queue)}" | |
| return state, top_img, bottom_img, top_text, bottom_text, progress | |
| def handle_like(state): | |
| return handle_preference(True, state) | |
| def handle_dislike(state): | |
| return handle_preference(False, state) | |
| def show_liked_outfits(): | |
| """Format liked outfits for display.""" | |
| liked = get_liked_outfits() | |
| if not liked: | |
| return "Aún no has guardado ningún outfit. Usa la tab Combina para aprobar looks." | |
| lines = [f"### Outfits guardados: {len(liked)}\n"] | |
| for outfit in liked: | |
| top = outfit["top"] | |
| bottom = outfit["bottom"] | |
| top_desc = top.get("description") or f"{top['color']} {top['type']}" | |
| bottom_desc = bottom.get("description") or f"{bottom['color']} {bottom['type']}" | |
| lines.append(f"- **{outfit['id']}**: {top_desc} + {bottom_desc}") | |
| return "\n".join(lines) | |
| # --------------------------------------------------------------------------- | |
| # UI Layout | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Wardrobe AI") as demo: | |
| gr.Markdown(HEADER) | |
| with gr.Tab("📸 Captura"): | |
| gr.Markdown( | |
| "Sube una foto de tu ropa. Puedes detectar prendas automáticamente " | |
| "o dibujar rectángulos sobre ellas para seleccionarlas manualmente." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1, min_width=200): | |
| capture_mode = gr.Radio( | |
| choices=[ | |
| ("Prenda individual", "single"), | |
| ("Múltiples prendas", "multi"), | |
| ], | |
| value="multi", | |
| label="Modo de captura", | |
| ) | |
| bbox_annotator = image_annotator( | |
| value=None, | |
| image_type="filepath", | |
| label_list=["prenda"], | |
| label="Sube una foto y dibuja rectángulos sobre las prendas (opcional)", | |
| ) | |
| with gr.Row(): | |
| auto_detect_btn = gr.Button( | |
| "Detectar automáticamente", variant="primary", size="lg", scale=2, | |
| ) | |
| manual_btn = gr.Button( | |
| "Procesar selección manual", variant="secondary", size="lg", scale=2, | |
| ) | |
| status_output = gr.Markdown(label="Estado") | |
| detected_table = gr.Dataframe( | |
| headers=["ID", "Tipo", "Color", "Material", "Patrón", "Temporada", "Estilo"], | |
| label="Prendas detectadas", | |
| interactive=False, | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown("#### Obtener dataset de ejemplo") | |
| with gr.Row(): | |
| dataset_select = gr.Dropdown( | |
| choices=[(label, key) for key, label in SAMPLE_DATASETS], | |
| value="second-hand", | |
| label="Dataset de origen", | |
| scale=2, | |
| ) | |
| obtener_btn = gr.Button( | |
| "Obtener Dataset", variant="primary", size="lg", scale=1, | |
| ) | |
| dataset_log = gr.Markdown() | |
| dataset_gallery = gr.Gallery( | |
| label="Prendas procesadas", | |
| columns=6, | |
| rows=2, | |
| height="auto", | |
| object_fit="contain", | |
| ) | |
| obtener_btn.click( | |
| obtener_dataset, | |
| inputs=[dataset_select], | |
| outputs=[dataset_log, dataset_gallery], | |
| ) | |
| with gr.Accordion("⚙️ Ajustes de detección", open=False): | |
| available_backends = list_available() | |
| if not available_backends: | |
| available_backends = [("YOLOS-tiny (HuggingFace)", "yolos")] | |
| current_backend = settings.get("detection_backend") | |
| backend_selector = gr.Radio( | |
| choices=available_backends, | |
| value=current_backend, | |
| label="Backend de detección automática", | |
| ) | |
| backend_status = gr.Markdown( | |
| value=f"Backend activo: **{current_backend}**" | |
| ) | |
| backend_selector.change( | |
| update_detection_backend, | |
| inputs=[backend_selector], | |
| outputs=[backend_status], | |
| ) | |
| auto_detect_btn.click( | |
| auto_detect, | |
| inputs=[bbox_annotator, capture_mode], | |
| outputs=[status_output, detected_table], | |
| ) | |
| manual_btn.click( | |
| process_manual_selection, | |
| inputs=[bbox_annotator], | |
| outputs=[status_output, detected_table], | |
| ) | |
| with gr.Tab("👗 Mi Armario"): | |
| gr.HTML("""<style> | |
| .garment-list { max-height: 420px; overflow-y: auto; } | |
| .garment-list .wrap { gap: 4px !important; } | |
| </style>""") | |
| catalog_stats = gr.Markdown(value=_format_catalog_stats()) | |
| with gr.Row(): | |
| refresh_btn = gr.Button("Actualizar", size="sm") | |
| clear_btn = gr.Button("Vaciar catálogo", variant="stop", size="sm") | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=1, min_width=220): | |
| garment_list = gr.Radio( | |
| choices=_get_catalog_choices(), | |
| label="Prendas", | |
| interactive=True, | |
| elem_classes=["garment-list"], | |
| ) | |
| with gr.Column(scale=2, min_width=400): | |
| with gr.Row(equal_height=True): | |
| garment_image = gr.Image( | |
| label="Foto", | |
| type="filepath", | |
| interactive=False, | |
| show_label=False, | |
| height=280, | |
| ) | |
| garment_detail = gr.Markdown( | |
| value="Selecciona una prenda de la lista." | |
| ) | |
| garment_list.change( | |
| select_garment, | |
| inputs=[garment_list], | |
| outputs=[garment_image, garment_detail], | |
| ) | |
| refresh_btn.click( | |
| refresh_catalog, | |
| outputs=[garment_list, catalog_stats, garment_image, garment_detail], | |
| ) | |
| clear_btn.click( | |
| clear_all, | |
| outputs=[garment_list, catalog_stats, garment_image, garment_detail], | |
| ) | |
| with gr.Tab("💬 Pregunta"): | |
| gr.Markdown("Pregúntale a tu armario. Responde en el idioma de tu pregunta.") | |
| chatbot = gr.ChatInterface( | |
| fn=chat_respond, | |
| examples=EXAMPLE_QUESTIONS, | |
| title=None, | |
| ) | |
| with gr.Tab("💫 Combina"): | |
| gr.Markdown( | |
| "Desliza entre combinaciones de outfits. " | |
| "**Top + Bottom** — ¿te gusta o no?" | |
| ) | |
| combo_state = gr.State(value=None) | |
| event_context = gr.Textbox( | |
| placeholder="Ej: cena informal en terraza, 30 grados...", | |
| label="Describe la ocasión (opcional — las combinaciones se ordenarán para este contexto)", | |
| lines=1, | |
| ) | |
| with gr.Row(): | |
| generate_btn = gr.Button( | |
| "Generar combinaciones", variant="primary", size="lg", | |
| ) | |
| combo_progress = gr.Markdown(value="—") | |
| with gr.Row(equal_height=True): | |
| with gr.Column(scale=1, min_width=200): | |
| gr.Markdown("#### Top") | |
| combo_top_img = gr.Image( | |
| label="Top", | |
| type="filepath", | |
| interactive=False, | |
| show_label=False, | |
| height=280, | |
| ) | |
| combo_top_text = gr.Markdown() | |
| with gr.Column(scale=1, min_width=200): | |
| gr.Markdown("#### Bottom") | |
| combo_bottom_img = gr.Image( | |
| label="Bottom", | |
| type="filepath", | |
| interactive=False, | |
| show_label=False, | |
| height=280, | |
| ) | |
| combo_bottom_text = gr.Markdown() | |
| with gr.Row(): | |
| dislike_btn = gr.Button("👎 No me gusta", variant="stop", size="lg", scale=1) | |
| like_btn = gr.Button("👍 Me gusta", variant="primary", size="lg", scale=1) | |
| gr.Markdown("---") | |
| liked_display = gr.Markdown(value=show_liked_outfits) | |
| refresh_liked_btn = gr.Button("Ver outfits guardados", size="sm") | |
| combo_outputs = [ | |
| combo_state, combo_top_img, combo_bottom_img, | |
| combo_top_text, combo_bottom_text, combo_progress, | |
| ] | |
| generate_btn.click( | |
| init_combinations, | |
| inputs=[combo_state, event_context], | |
| outputs=combo_outputs, | |
| ) | |
| like_btn.click( | |
| handle_like, | |
| inputs=[combo_state], | |
| outputs=combo_outputs, | |
| ) | |
| dislike_btn.click( | |
| handle_dislike, | |
| inputs=[combo_state], | |
| outputs=combo_outputs, | |
| ) | |
| refresh_liked_btn.click(show_liked_outfits, outputs=[liked_display]) | |
| with gr.Accordion("Acerca del proyecto", open=False): | |
| gr.Markdown( | |
| """ | |
| **Wardrobe AI** transforma un armario físico en un catálogo digital consultable. | |
| - **Modelo**: Gemma 3 4B IT (Q4_K_M GGUF) — 4B parámetros | |
| - **Inferencia**: llama.cpp via llama-cpp-python — 100% local | |
| - **Track**: Backyard AI — "Resuelve un problema real para alguien que conoces" | |
| - **Badges**: Tiny Titan (≤4B) · Off the Grid (sin APIs) · Llama Champion (llama.cpp) | |
| Construido para el [Build Small Hackathon](https://huggingface.co/build-small-hackathon) | |
| (HuggingFace × Gradio, junio 2026). | |
| """ | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Custom frontend (gr.Server + Alpine.js) | |
| # --------------------------------------------------------------------------- | |
| def _build_custom_server(): | |
| """Build the gr.Server app with API endpoints and custom HTML frontend.""" | |
| from gradio import Server | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from src.assistant import ask | |
| from src.storage import get_image_path | |
| UI_DIR = Path(__file__).resolve().parent / "src" / "ui" | |
| server = Server() | |
| server.mount("/garments", StaticFiles(directory=str(_DATA_DIR / "garments")), name="garments") | |
| _UPLOADS_DIR = _DATA_DIR / "_uploads" | |
| _UPLOADS_DIR.mkdir(parents=True, exist_ok=True) | |
| server.mount("/uploads", StaticFiles(directory=str(_UPLOADS_DIR)), name="uploads") | |
| def _image_url(garment_id: str) -> str: | |
| """Return a cache-busted URL for a garment image using file mtime.""" | |
| img_path = get_image_path(garment_id) | |
| if img_path: | |
| try: | |
| v = int(os.path.getmtime(img_path)) | |
| except OSError: | |
| v = 0 | |
| else: | |
| v = 0 | |
| return f"/garments/{garment_id}.jpg?v={v}" | |
| def api_prepare_image(image_path: str | dict) -> dict: | |
| """Save image to _uploads, run auto-detection, return token + detected boxes. | |
| The frontend uses the token + image_url to show the image in Annotorious | |
| pre-populated with auto-detected boxes for the user to review/edit. | |
| """ | |
| import uuid | |
| import time as _time | |
| if isinstance(image_path, dict): | |
| image_path = image_path.get("path") or image_path.get("url", "") | |
| try: | |
| img = Image.open(str(image_path)).convert("RGB") | |
| except Exception as e: | |
| return {"error": str(e), "token": "", "image_url": "", "width": 0, "height": 0, "boxes": []} | |
| img.thumbnail((1280, 1280), Image.LANCZOS) | |
| w, h = img.size | |
| token = uuid.uuid4().hex | |
| upload_path = _UPLOADS_DIR / f"{token}.jpg" | |
| img.save(str(upload_path), format="JPEG", quality=90) | |
| try: | |
| boxes = detect_boxes(str(upload_path)) | |
| except Exception: | |
| boxes = [] | |
| ts = int(_time.time()) | |
| return { | |
| "token": token, | |
| "image_url": f"/uploads/{token}.jpg?v={ts}", | |
| "width": w, | |
| "height": h, | |
| "boxes": [{"x": b.x1, "y": b.y1, "w": b.width, "h": b.height} for b in boxes], | |
| } | |
| def api_analyze_boxes(token: str, boxes: str) -> dict: | |
| """Crop each user-confirmed bounding box from the uploaded image and extract garment attributes. | |
| Args: | |
| token: filename token returned by prepare_image (hex, no path separators). | |
| boxes: JSON string — list of {x, y, w, h} in pixels of the stored image. | |
| """ | |
| import json as _json | |
| import re as _re | |
| # Validate token: only hex characters, no path traversal | |
| if not _re.fullmatch(r"[0-9a-f]{32}", token): | |
| return {"error": "Invalid token", "count": 0, "garments": []} | |
| upload_path = _UPLOADS_DIR / f"{token}.jpg" | |
| if not upload_path.exists(): | |
| return {"error": "Image not found. Please re-upload.", "count": 0, "garments": []} | |
| try: | |
| box_list = _json.loads(boxes) | |
| except Exception: | |
| return {"error": "Invalid boxes format", "count": 0, "garments": []} | |
| try: | |
| img = Image.open(str(upload_path)).convert("RGB") | |
| except Exception as e: | |
| return {"error": str(e), "count": 0, "garments": []} | |
| results: list[tuple[dict, bytes]] = [] | |
| for box in box_list: | |
| try: | |
| x = int(box["x"]) | |
| y = int(box["y"]) | |
| w = int(box["w"]) | |
| h = int(box["h"]) | |
| except (KeyError, TypeError, ValueError): | |
| continue | |
| if w < 10 or h < 10: | |
| continue | |
| cropped = img.crop((x, y, x + w, y + h)) | |
| cropped.thumbnail((512, 512), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| cropped.save(buf, format="JPEG", quality=85) | |
| crop_bytes = buf.getvalue() | |
| result = extract_from_crop_bytes(crop_bytes) | |
| if result: | |
| results.append(result) | |
| # Cleanup upload temp file | |
| try: | |
| upload_path.unlink() | |
| except OSError: | |
| pass | |
| if not results: | |
| return {"error": "No garments could be extracted from the selections.", "count": 0, "garments": []} | |
| added = add_garments(results) | |
| for g in added: | |
| g["image_url"] = _image_url(g["id"]) | |
| return {"count": len(added), "garments": added} | |
| def api_get_wardrobe() -> dict: | |
| catalog = load_catalog() | |
| for g in catalog: | |
| g["image_url"] = _image_url(g.get("id", "")) | |
| return {"garments": catalog, "count": len(catalog)} | |
| def api_add_photo(image_path: str | dict) -> dict: | |
| if isinstance(image_path, dict): | |
| image_path = image_path.get("path") or image_path.get("url", "") | |
| results = extract_garments(str(image_path)) | |
| if not results: | |
| return {"garments": [], "count": 0} | |
| added = add_garments(results) | |
| for g in added: | |
| g["image_url"] = _image_url(g["id"]) | |
| return {"garments": added, "count": len(added)} | |
| def api_get_combinations(context: str = "") -> dict: | |
| combos = generate_combinations() | |
| if not combos: | |
| return {"combinations": [], "count": 0} | |
| combos = rank_with_llm(combos, context.strip() if context else "") | |
| serialized = [] | |
| for combo in combos[:20]: | |
| top, bottom = combo["top"], combo["bottom"] | |
| serialized.append({ | |
| "id": combo["id"], | |
| "top": {"id": top.get("id", ""), "type": top.get("type", ""), | |
| "color": top.get("color", ""), "image_url": _image_url(top.get("id", ""))}, | |
| "bottom": {"id": bottom.get("id", ""), "type": bottom.get("type", ""), | |
| "color": bottom.get("color", ""), "image_url": _image_url(bottom.get("id", ""))}, | |
| }) | |
| return {"combinations": serialized, "count": len(serialized)} | |
| def api_rate_outfit(top_id: str, bottom_id: str, liked: bool) -> dict: | |
| save_preference(top_id, bottom_id, liked) | |
| return {"status": "ok", "liked": liked} | |
| def api_ask_question(question: str) -> str: | |
| if not question or not question.strip(): | |
| return "Please ask a question about your wardrobe." | |
| return ask(question.strip()) | |
| def api_load_dataset(dataset_key: str) -> dict: | |
| """Stream dataset processing progress as each garment is analyzed. | |
| Yields progress dicts with: done, count, log[], preview_url. | |
| The final yield has done=True with the total count. | |
| """ | |
| import time as _time | |
| from datasets import load_dataset as hf_load | |
| _load_ts = int(_time.time()) | |
| ds_configs = { | |
| "second-hand": {"hf_id": "fnauman/fashion-second-hand-front-only-rgb", "needs_detection": False}, | |
| "fashion-1k": {"hf_id": "Codatta/Fashion-1K", "needs_detection": True}, | |
| } | |
| config = ds_configs.get(dataset_key) | |
| if not config: | |
| yield {"done": True, "error": "Unknown dataset", "count": 0, "log": ["Error: dataset not recognized."], "preview_url": None} | |
| return | |
| log_lines: list[str] = [f"Downloading {config['hf_id']}..."] | |
| yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None} | |
| try: | |
| ds = hf_load(config["hf_id"], split="train") | |
| except Exception as e: | |
| yield {"done": True, "error": str(e), "count": 0, "log": [f"Error downloading dataset: {e}"], "preview_url": None} | |
| return | |
| log_lines.append(f"Dataset loaded: {len(ds)} images. Starting processing...") | |
| yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None} | |
| target = TARGET_GARMENTS | |
| step = max(1, len(ds) // (target * 2)) | |
| indices = list(range(0, len(ds), step))[:target * 3] | |
| garments_processed: list[tuple[dict, bytes]] = [] | |
| for idx in indices: | |
| if len(garments_processed) >= target: | |
| break | |
| if idx >= len(ds): | |
| continue | |
| sample = ds[idx] | |
| image = sample.get("image") or sample.get("img") | |
| if not isinstance(image, Image.Image): | |
| continue | |
| if image.mode != "RGB": | |
| image = image.convert("RGB") | |
| if config["needs_detection"]: | |
| import tempfile | |
| with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: | |
| image.save(tmp, format="JPEG", quality=90) | |
| tmp_path = tmp.name | |
| try: | |
| from src.detector import detect_and_crop | |
| crops = detect_and_crop(tmp_path) | |
| except Exception: | |
| crops = [] | |
| finally: | |
| Path(tmp_path).unlink(missing_ok=True) | |
| for crop_bytes in crops: | |
| if len(garments_processed) >= target: | |
| break | |
| garment = _extract_single_garment(crop_bytes) | |
| if garment: | |
| garments_processed.append((garment, crop_bytes)) | |
| n = len(garments_processed) | |
| preview_path = _save_temp_preview(crop_bytes, n) | |
| preview_url = f"/garments/_preview_{n:03d}.jpg?v={_load_ts}" if preview_path else None | |
| log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}") | |
| yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url} | |
| else: | |
| if max(image.size) > 512: | |
| image.thumbnail((512, 512), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| image.save(buf, format="JPEG", quality=90) | |
| crop_bytes = buf.getvalue() | |
| garment = _extract_single_garment(crop_bytes) | |
| if garment: | |
| garments_processed.append((garment, crop_bytes)) | |
| n = len(garments_processed) | |
| preview_path = _save_temp_preview(crop_bytes, n) | |
| preview_url = f"/garments/_preview_{n:03d}.jpg?v={_load_ts}" if preview_path else None | |
| log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}") | |
| yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url} | |
| if not garments_processed: | |
| yield {"done": True, "error": "No garments extracted", "count": 0, "log": log_lines + ["No garments could be extracted."], "preview_url": None} | |
| return | |
| added = add_garments(garments_processed) | |
| log_lines.append(f"Done: {len(added)} garments added to your wardrobe.") | |
| yield {"done": True, "count": len(added), "log": log_lines[-12:], "preview_url": None} | |
| async def homepage(): | |
| return FileResponse(str(UI_DIR / "index.html"), media_type="text/html") | |
| async def styles(): | |
| return FileResponse(str(UI_DIR / "style.css"), media_type="text/css") | |
| return server | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Wardrobe AI") | |
| group = parser.add_mutually_exclusive_group() | |
| group.add_argument( | |
| "--ui", action="store_true", default=True, | |
| help="Launch custom frontend (default)", | |
| ) | |
| group.add_argument( | |
| "--default", action="store_true", | |
| help="Launch standard Gradio Blocks UI", | |
| ) | |
| args = parser.parse_args() | |
| is_spaces = os.environ.get("SPACE_ID") is not None | |
| server_name = "0.0.0.0" if is_spaces else "127.0.0.1" | |
| if args.default: | |
| demo.launch( | |
| server_name=server_name, | |
| server_port=7860, | |
| theme=gr.themes.Soft( | |
| primary_hue="stone", | |
| secondary_hue="amber", | |
| ), | |
| ) | |
| else: | |
| server = _build_custom_server() | |
| server.launch( | |
| server_name=server_name, | |
| server_port=7860, | |
| ) | |