Spaces:
Sleeping
Sleeping
| """ | |
| Wonder Finder β Visual recommender for the 12 Wonders of the World. | |
| HF Spaces deployment. | |
| Notes on defensive patches: | |
| - gradio 4.44.x has a known bug in gradio_client/utils.py where api_info | |
| schema generation crashes on `additionalProperties: True` (boolean schema). | |
| - The bug raises gradio_client.utils.APIInfoParseError, which is NOT a | |
| subclass of TypeError/KeyError/AttributeError β so naive try/except misses it. | |
| - We patch at THREE layers: get_type, _json_schema_to_python_type, and | |
| Blocks.get_api_info. Each catches Exception (the broadest possible). | |
| """ | |
| import os | |
| # Belt-and-suspenders env vars | |
| os.environ["GRADIO_ANALYTICS_ENABLED"] = "False" | |
| os.environ["GRADIO_SERVER_NAME"] = "0.0.0.0" | |
| # ============================================================ | |
| # DEFENSIVE PATCHES β must run before any Gradio component init | |
| # ============================================================ | |
| import gradio_client.utils as _gcu | |
| # Patch 1: _json_schema_to_python_type β the inner recursive function | |
| _original_json_schema = _gcu._json_schema_to_python_type | |
| def _safe_json_schema(schema, defs=None): | |
| # Handle boolean schemas (the actual bug trigger) | |
| if isinstance(schema, bool): | |
| return "Any" | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| try: | |
| return _original_json_schema(schema, defs) | |
| except Exception: | |
| return "Any" | |
| _gcu._json_schema_to_python_type = _safe_json_schema | |
| # Patch 2: get_type β wraps the entry-point type checker | |
| _original_get_type = _gcu.get_type | |
| def _safe_get_type(schema): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| try: | |
| return _original_get_type(schema) | |
| except Exception: | |
| return "Any" | |
| _gcu.get_type = _safe_get_type | |
| # Patch 3: top-level api_info generator β safety net for anything we missed | |
| import gradio as gr | |
| import gradio.blocks as _gradio_blocks | |
| _original_get_api_info = _gradio_blocks.Blocks.get_api_info | |
| def _safe_get_api_info(self): | |
| try: | |
| return _original_get_api_info(self) | |
| except Exception: | |
| return {"named_endpoints": {}, "unnamed_endpoints": {}} | |
| _gradio_blocks.Blocks.get_api_info = _safe_get_api_info | |
| # ============================================================ | |
| # REGULAR IMPORTS | |
| # ============================================================ | |
| import torch | |
| import numpy as np | |
| import pandas as pd | |
| from PIL import Image | |
| from transformers import CLIPProcessor, CLIPModel | |
| from datasets import load_dataset, concatenate_datasets | |
| # ============================================================ | |
| # LOAD EVERYTHING ON STARTUP | |
| # ============================================================ | |
| print("Loading CLIP model...") | |
| MODEL_NAME = "openai/clip-vit-base-patch32" | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| clip_model = CLIPModel.from_pretrained(MODEL_NAME).to(device) | |
| clip_model.eval() | |
| processor = CLIPProcessor.from_pretrained(MODEL_NAME) | |
| print("Loading dataset...") | |
| ds = load_dataset("chavajaz/wonders_dataset") | |
| full_ds = concatenate_datasets([ds["train"], ds["validation"], ds["test"]]) | |
| class_names = full_ds.features["label"].names | |
| print("Loading precomputed embeddings...") | |
| embeddings_df = pd.read_parquet("wonders_embeddings.parquet") | |
| image_embeddings = np.array(embeddings_df["embedding"].tolist(), dtype=np.float32) | |
| EMBEDDINGS_TENSOR = torch.tensor(image_embeddings, device=device, dtype=torch.float32) | |
| print(f"Ready. {len(full_ds)} images, embeddings {image_embeddings.shape}, on {device}") | |
| # ============================================================ | |
| # CORE FUNCTIONS | |
| # ============================================================ | |
| def embed_image(pil_image): | |
| img = pil_image.convert("RGB") | |
| inputs = processor(images=img, return_tensors="pt").to(device) | |
| feats = clip_model.get_image_features(**inputs) | |
| if not isinstance(feats, torch.Tensor): | |
| if hasattr(feats, "image_embeds") and feats.image_embeds is not None: | |
| feats = feats.image_embeds | |
| elif hasattr(feats, "pooler_output") and feats.pooler_output is not None: | |
| feats = feats.pooler_output | |
| else: | |
| feats = feats[0] | |
| feats = feats / feats.norm(dim=-1, keepdim=True) | |
| return feats | |
| def embed_text(text): | |
| inputs = processor(text=[text], return_tensors="pt", padding=True, truncation=True).to(device) | |
| feats = clip_model.get_text_features(**inputs) | |
| if not isinstance(feats, torch.Tensor): | |
| if hasattr(feats, "text_embeds") and feats.text_embeds is not None: | |
| feats = feats.text_embeds | |
| elif hasattr(feats, "pooler_output") and feats.pooler_output is not None: | |
| feats = feats.pooler_output | |
| else: | |
| feats = feats[0] | |
| feats = feats / feats.norm(dim=-1, keepdim=True) | |
| return feats | |
| def recommend(query_embedding, top_k=3, diversity_threshold=0.98): | |
| sims = (query_embedding @ EMBEDDINGS_TENSOR.T).squeeze(0) | |
| top_scores, top_indices = sims.topk(min(top_k * 20, len(sims))) | |
| top_scores = top_scores.cpu().tolist() | |
| top_indices = top_indices.cpu().tolist() | |
| results = [] | |
| chosen_embeddings = [] | |
| for score, idx in zip(top_scores, top_indices): | |
| candidate_emb = EMBEDDINGS_TENSOR[idx] | |
| too_similar = any( | |
| (candidate_emb @ prev_emb).item() > diversity_threshold | |
| for prev_emb in chosen_embeddings | |
| ) | |
| if too_similar: | |
| continue | |
| item = full_ds[idx] | |
| results.append({ | |
| "index": idx, | |
| "score": score, | |
| "image": item["image"], | |
| "label_name": class_names[item["label"]], | |
| }) | |
| chosen_embeddings.append(candidate_emb) | |
| if len(results) >= top_k: | |
| break | |
| return results | |
| def recommend_from_image(input_image): | |
| if input_image is None: | |
| return [], "Please upload an image to find matching wonders." | |
| query_emb = embed_image(input_image) | |
| results = recommend(query_emb, top_k=3) | |
| gallery_items = [ | |
| (r["image"], f"{r['label_name'].replace('_', ' ').title()} β’ match {r['score']*100:.1f}%") | |
| for r in results | |
| ] | |
| medals = ["π₯", "π₯", "π₯"] | |
| summary = "Your top 3 wonder matches:\n\n" + "\n".join( | |
| f"{medals[i]} {r['label_name'].replace('_', ' ').title()} β similarity {r['score']:.3f}" | |
| for i, r in enumerate(results) | |
| ) | |
| return gallery_items, summary | |
| def recommend_from_text(text_query): | |
| if not text_query or not text_query.strip(): | |
| return [], "Please describe what you're looking for." | |
| query_emb = embed_text(text_query) | |
| results = recommend(query_emb, top_k=3) | |
| gallery_items = [ | |
| (r["image"], f"{r['label_name'].replace('_', ' ').title()} β’ match {r['score']*100:.1f}%") | |
| for r in results | |
| ] | |
| medals = ["π₯", "π₯", "π₯"] | |
| summary = f'Best matches for "{text_query}":\n\n' + "\n".join( | |
| f"{medals[i]} {r['label_name'].replace('_', ' ').title()} β similarity {r['score']:.3f}" | |
| for i, r in enumerate(results) | |
| ) | |
| return gallery_items, summary | |
| # ============================================================ | |
| # UI | |
| # ============================================================ | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&family=Nunito:wght@400;600;700;800&display=swap'); | |
| .gradio-container { | |
| background: linear-gradient(135deg, #F5EBDD 0%, #EDE0CC 100%) !important; | |
| font-family: 'Nunito', 'Quicksand', -apple-system, sans-serif !important; | |
| } | |
| h1, h2, h3, h4 { | |
| font-family: 'Quicksand', sans-serif !important; | |
| letter-spacing: 0.3px !important; | |
| } | |
| #header-block { | |
| background: linear-gradient(135deg, #8B4513 0%, #A0522D 50%, #CD853F 100%); | |
| padding: 36px 28px; | |
| border-radius: 20px; | |
| margin-bottom: 28px; | |
| box-shadow: 0 8px 24px rgba(139, 69, 19, 0.25); | |
| text-align: center; | |
| } | |
| #header-block h1 { | |
| color: #FFF8E7 !important; | |
| font-size: 2.8em !important; | |
| font-weight: 700 !important; | |
| margin: 0 !important; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.2); | |
| } | |
| #header-block h3 { | |
| color: #FFE4B5 !important; | |
| font-weight: 500 !important; | |
| margin: 10px 0 0 0 !important; | |
| } | |
| #header-block p { | |
| color: #FFF8E7 !important; | |
| margin-top: 14px !important; | |
| font-size: 1.05em !important; | |
| opacity: 0.95; | |
| } | |
| .tab-nav { | |
| background: transparent !important; | |
| border-bottom: none !important; | |
| gap: 12px !important; | |
| padding: 0 4px !important; | |
| margin-bottom: 8px !important; | |
| } | |
| .tab-nav button { | |
| background: #FFF8E7 !important; | |
| border: 2px solid #D2B48C !important; | |
| color: #8B4513 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-size: 1.15em !important; | |
| font-weight: 700 !important; | |
| padding: 14px 32px !important; | |
| border-radius: 14px !important; | |
| margin: 0 !important; | |
| box-shadow: 0 2px 6px rgba(139, 69, 19, 0.12) !important; | |
| transition: all 0.25s ease !important; | |
| cursor: pointer !important; | |
| } | |
| .tab-nav button:hover { | |
| background: #FFE8C8 !important; | |
| border-color: #A0522D !important; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(139, 69, 19, 0.25) !important; | |
| } | |
| .tab-nav button.selected { | |
| background: linear-gradient(135deg, #8B4513 0%, #A0522D 100%) !important; | |
| border-color: #8B4513 !important; | |
| color: #FFF8E7 !important; | |
| box-shadow: 0 6px 16px rgba(139, 69, 19, 0.4) !important; | |
| transform: translateY(-2px); | |
| } | |
| button.primary, .gr-button-primary { | |
| background: linear-gradient(135deg, #8B4513 0%, #A0522D 100%) !important; | |
| border: none !important; | |
| color: #FFF8E7 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 1.08em !important; | |
| padding: 14px 30px !important; | |
| border-radius: 12px !important; | |
| box-shadow: 0 4px 12px rgba(139, 69, 19, 0.3) !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| button.primary:hover, .gr-button-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 16px rgba(139, 69, 19, 0.45) !important; | |
| } | |
| .gr-box, .gr-form, .gr-panel { | |
| background: #FFF8E7 !important; | |
| border: 2px solid #D2B48C !important; | |
| border-radius: 14px !important; | |
| } | |
| label, .gr-input-label { | |
| color: #5C4033 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 1em !important; | |
| } | |
| textarea, input[type="text"] { | |
| background: #FFFAF0 !important; | |
| border: 2px solid #D2B48C !important; | |
| color: #3E2723 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-size: 1.02em !important; | |
| border-radius: 10px !important; | |
| padding: 12px !important; | |
| } | |
| textarea:focus, input[type="text"]:focus { | |
| border-color: #8B4513 !important; | |
| outline: none !important; | |
| box-shadow: 0 0 0 3px rgba(139, 69, 19, 0.15) !important; | |
| } | |
| .gr-gallery { | |
| background: #FFF8E7 !important; | |
| border: 2px solid #D2B48C !important; | |
| border-radius: 14px !important; | |
| padding: 10px !important; | |
| } | |
| #footer-block { | |
| margin-top: 28px; | |
| padding: 22px 24px; | |
| background: rgba(139, 69, 19, 0.08); | |
| border-radius: 14px; | |
| border-left: 5px solid #8B4513; | |
| color: #5C4033 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| line-height: 1.7; | |
| } | |
| #footer-block a { | |
| color: #8B4513 !important; | |
| font-weight: 700; | |
| text-decoration: none; | |
| border-bottom: 1px dashed #8B4513; | |
| } | |
| #footer-block a:hover { | |
| color: #A0522D !important; | |
| } | |
| """ | |
| with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft( | |
| primary_hue="orange", secondary_hue="amber", neutral_hue="stone", | |
| ), title="Wonder Finder") as demo: | |
| gr.HTML(""" | |
| <div id="header-block"> | |
| <h1>π Wonder Finder</h1> | |
| <h3>Discover the World's 12 Wonders Through AI Vision</h3> | |
| <p>Upload a travel photo or describe a place β get the closest matches from 11,544 images.<br> | |
| Powered by CLIP's joint imageβtext embedding space.</p> | |
| </div> | |
| """) | |
| with gr.Tabs(): | |
| with gr.Tab("π· Search by Image"): | |
| gr.Markdown("### Upload your travel photo, and we'll find the wonders that look most like it.") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| img_input = gr.Image(type="pil", label="Drop your photo here", height=320) | |
| img_btn = gr.Button("β¨ Find Similar Wonders", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| img_gallery = gr.Gallery(label="Top 3 Matches", columns=3, rows=1, height=320, object_fit="cover") | |
| img_summary = gr.Textbox(label="π Match Details", lines=6, show_copy_button=True) | |
| img_btn.click(recommend_from_image, inputs=img_input, outputs=[img_gallery, img_summary]) | |
| with gr.Tab("π¬ Search by Description"): | |
| gr.Markdown("### Describe a place in your own words β CLIP translates language into visual matches.") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| text_input = gr.Textbox( | |
| label="Describe a wonder", | |
| placeholder='e.g. "an ancient stone temple in the jungle" or "a tall tower at sunset"', | |
| lines=3, | |
| ) | |
| text_btn = gr.Button("β¨ Find Matching Wonders", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| text_gallery = gr.Gallery(label="Top 3 Matches", columns=3, rows=1, height=320, object_fit="cover") | |
| text_summary = gr.Textbox(label="π Match Details", lines=6, show_copy_button=True) | |
| text_btn.click(recommend_from_text, inputs=text_input, outputs=[text_gallery, text_summary]) | |
| with gr.Accordion("π¬ Watch the project presentation", open=False): | |
| gr.Video(value="Video.mp4", label=None, show_label=False) | |
| gr.HTML(""" | |
| <div id="footer-block"> | |
| <strong>About this app</strong><br> | |
| <strong>Dataset:</strong> <a href="https://huggingface.co/datasets/chavajaz/wonders_dataset">chavajaz/wonders_dataset</a> β 11,544 images across 12 wonder classes (CC0).<br> | |
| <strong>Model:</strong> <a href="https://huggingface.co/openai/clip-vit-base-patch32">CLIP ViT-B/32</a> β embeds images and text into the same 512-D space for cross-modal retrieval.<br> | |
| <strong>Method:</strong> L2-normalized cosine similarity over precomputed embeddings, with a diversity filter (threshold 0.98) to suppress near-duplicate results. | |
| </div> | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_api=False, | |
| share=False, | |
| ) | |