""" 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 # ============================================================ @torch.no_grad() 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 @torch.no_grad() 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("""
Upload a travel photo or describe a place — get the closest matches from 11,544 images.
Powered by CLIP's joint image–text embedding space.