Jonathandav commited on
Commit
6f5366c
·
verified ·
1 Parent(s): 5dbcec5

Upload 4 files

Browse files
Files changed (4) hide show
  1. README (1).md +38 -0
  2. app (1).py +374 -0
  3. requirements (1).txt +9 -0
  4. wonders_embeddings.parquet +3 -0
README (1).md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Wonder Finder
3
+ emoji: 🌍
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # 🌍 Wonder Finder
14
+
15
+ Visual recommender for the 12 Wonders of the World, powered by CLIP embeddings.
16
+
17
+ ## What it does
18
+ - **Image search:** upload a travel photo → get the 3 most visually similar wonders
19
+ - **Text search:** describe a place in natural language → get the 3 closest matching wonders
20
+
21
+ ## How it works
22
+ 1. The catalog (11,544 images across 12 wonder classes) is pre-embedded using CLIP ViT-B/32.
23
+ 2. User input (image or text) is embedded into the same 512-D space.
24
+ 3. Cosine similarity ranks the catalog; the top 3 results are returned with a diversity filter to avoid duplicates.
25
+
26
+ ## Dataset
27
+ [chavajaz/wonders_dataset](https://huggingface.co/datasets/chavajaz/wonders_dataset) — CC0-1.0 licensed, ~960 images per class on average.
28
+
29
+ ## Model
30
+ [openai/clip-vit-base-patch32](https://huggingface.co/openai/clip-vit-base-patch32) — chosen for its joint image-text embedding space, which enables both image and text input through a single model.
31
+
32
+ ## Cluster analysis
33
+ K-Means at k=12 on the embeddings achieved **ARI = 0.890** and **NMI = 0.927** against ground-truth wonder labels, indicating CLIP's pretrained space already separates the 12 wonders almost perfectly without supervision.
34
+
35
+ ## Files
36
+ - `app.py` — the Gradio application
37
+ - `requirements.txt` — pinned dependencies
38
+ - `wonders_embeddings.parquet` — precomputed CLIP embeddings (one row per catalog image, column `embedding`, aligned 1:1 with the dataset in `train → validation → test` order)
app (1).py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import torch
3
+ import numpy as np
4
+ import pandas as pd
5
+ from transformers import CLIPProcessor, CLIPModel
6
+ from datasets import load_dataset, concatenate_datasets
7
+
8
+ # ============================================================
9
+ # LOAD EVERYTHING ON STARTUP (runs once when the Space boots)
10
+ # ============================================================
11
+
12
+ print("Loading CLIP model...")
13
+ MODEL_NAME = "openai/clip-vit-base-patch32"
14
+ device = "cuda" if torch.cuda.is_available() else "cpu"
15
+ clip_model = CLIPModel.from_pretrained(MODEL_NAME).to(device)
16
+ clip_model.eval()
17
+ processor = CLIPProcessor.from_pretrained(MODEL_NAME)
18
+
19
+ print("Loading dataset...")
20
+ ds = load_dataset("chavajaz/wonders_dataset")
21
+ # Concatenate whatever splits exist, in a stable order, instead of assuming
22
+ # train/validation/test are all present.
23
+ split_order = [s for s in ["train", "validation", "test"] if s in ds]
24
+ split_order += [s for s in ds.keys() if s not in split_order]
25
+ splits = [ds[s] for s in split_order]
26
+ full_ds = concatenate_datasets(splits) if len(splits) > 1 else splits[0]
27
+ class_names = full_ds.features["label"].names
28
+
29
+ print("Loading precomputed embeddings...")
30
+ embeddings_df = pd.read_parquet("wonders_embeddings.parquet")
31
+ image_embeddings = np.array(embeddings_df["embedding"].tolist(), dtype=np.float32)
32
+ EMBEDDINGS_TENSOR = torch.tensor(image_embeddings, device=device, dtype=torch.float32)
33
+ # Defensively L2-normalize so cosine similarity and the diversity threshold
34
+ # are correct even if the stored vectors weren't normalized.
35
+ EMBEDDINGS_TENSOR = EMBEDDINGS_TENSOR / EMBEDDINGS_TENSOR.norm(dim=-1, keepdim=True).clamp_min(1e-12)
36
+
37
+ if len(full_ds) != EMBEDDINGS_TENSOR.shape[0]:
38
+ print(
39
+ f"WARNING: dataset has {len(full_ds)} images but the embeddings file has "
40
+ f"{EMBEDDINGS_TENSOR.shape[0]} rows. They must line up 1:1 and be in the "
41
+ f"same order for results to be correct."
42
+ )
43
+
44
+ print(f"Ready. {len(full_ds)} images, embeddings {image_embeddings.shape}, on {device}")
45
+
46
+ # ============================================================
47
+ # CORE FUNCTIONS
48
+ # ============================================================
49
+
50
+ @torch.no_grad()
51
+ def embed_image(pil_image):
52
+ img = pil_image.convert("RGB")
53
+ inputs = processor(images=img, return_tensors="pt").to(device)
54
+ feats = clip_model.get_image_features(**inputs)
55
+ if not isinstance(feats, torch.Tensor):
56
+ if hasattr(feats, "image_embeds") and feats.image_embeds is not None:
57
+ feats = feats.image_embeds
58
+ elif hasattr(feats, "pooler_output") and feats.pooler_output is not None:
59
+ feats = feats.pooler_output
60
+ else:
61
+ feats = feats[0]
62
+ feats = feats / feats.norm(dim=-1, keepdim=True)
63
+ return feats
64
+
65
+ @torch.no_grad()
66
+ def embed_text(text):
67
+ inputs = processor(text=[text], return_tensors="pt", padding=True, truncation=True).to(device)
68
+ feats = clip_model.get_text_features(**inputs)
69
+ if not isinstance(feats, torch.Tensor):
70
+ if hasattr(feats, "text_embeds") and feats.text_embeds is not None:
71
+ feats = feats.text_embeds
72
+ elif hasattr(feats, "pooler_output") and feats.pooler_output is not None:
73
+ feats = feats.pooler_output
74
+ else:
75
+ feats = feats[0]
76
+ feats = feats / feats.norm(dim=-1, keepdim=True)
77
+ return feats
78
+
79
+ def recommend(query_embedding, top_k=3, diversity_threshold=0.98):
80
+ sims = (query_embedding @ EMBEDDINGS_TENSOR.T).squeeze(0)
81
+ top_scores, top_indices = sims.topk(min(top_k * 20, len(sims)))
82
+ top_scores = top_scores.cpu().tolist()
83
+ top_indices = top_indices.cpu().tolist()
84
+
85
+ results = []
86
+ chosen_embeddings = []
87
+ for score, idx in zip(top_scores, top_indices):
88
+ candidate_emb = EMBEDDINGS_TENSOR[idx]
89
+ too_similar = any(
90
+ (candidate_emb @ prev_emb).item() > diversity_threshold
91
+ for prev_emb in chosen_embeddings
92
+ )
93
+ if too_similar:
94
+ continue
95
+ item = full_ds[idx]
96
+ results.append({
97
+ "index": idx,
98
+ "score": score,
99
+ "image": item["image"],
100
+ "label_name": class_names[item["label"]],
101
+ })
102
+ chosen_embeddings.append(candidate_emb)
103
+ if len(results) >= top_k:
104
+ break
105
+ return results
106
+
107
+ def recommend_from_image(input_image):
108
+ if input_image is None:
109
+ return [], "✋ Please upload an image to find matching wonders."
110
+ query_emb = embed_image(input_image)
111
+ results = recommend(query_emb, top_k=3)
112
+ gallery_items = [
113
+ (r["image"], f"{r['label_name'].replace('_', ' ').title()} • match {r['score']*100:.1f}%")
114
+ for r in results
115
+ ]
116
+ medals = ["🥇", "🥈", "🥉"]
117
+ summary = "Your top 3 wonder matches:\n\n" + "\n".join(
118
+ f"{medals[i]} {r['label_name'].replace('_', ' ').title():<22} similarity {r['score']:.3f}"
119
+ for i, r in enumerate(results)
120
+ )
121
+ return gallery_items, summary
122
+
123
+ def recommend_from_text(text_query):
124
+ if not text_query or not text_query.strip():
125
+ return [], "✋ Please describe what you're looking for."
126
+ query_emb = embed_text(text_query)
127
+ results = recommend(query_emb, top_k=3)
128
+ gallery_items = [
129
+ (r["image"], f"{r['label_name'].replace('_', ' ').title()} • match {r['score']*100:.1f}%")
130
+ for r in results
131
+ ]
132
+ medals = ["🥇", "🥈", "🥉"]
133
+ summary = f'Best matches for "{text_query}":\n\n' + "\n".join(
134
+ f"{medals[i]} {r['label_name'].replace('_', ' ').title():<22} similarity {r['score']:.3f}"
135
+ for i, r in enumerate(results)
136
+ )
137
+ return gallery_items, summary
138
+
139
+ # ============================================================
140
+ # UI
141
+ # ============================================================
142
+
143
+ CUSTOM_CSS = """
144
+ @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&family=Nunito:wght@400;600;700;800&display=swap');
145
+
146
+ .gradio-container {
147
+ background: linear-gradient(135deg, #F5EBDD 0%, #EDE0CC 100%) !important;
148
+ font-family: 'Nunito', 'Quicksand', -apple-system, sans-serif !important;
149
+ }
150
+
151
+ /* Headings get the rounder, friendlier Quicksand */
152
+ h1, h2, h3, h4 {
153
+ font-family: 'Quicksand', sans-serif !important;
154
+ letter-spacing: 0.3px !important;
155
+ }
156
+
157
+ /* ---------- HEADER ---------- */
158
+ #header-block {
159
+ background: linear-gradient(135deg, #8B4513 0%, #A0522D 50%, #CD853F 100%);
160
+ padding: 36px 28px;
161
+ border-radius: 20px;
162
+ margin-bottom: 28px;
163
+ box-shadow: 0 8px 24px rgba(139, 69, 19, 0.25);
164
+ text-align: center;
165
+ }
166
+ #header-block h1 {
167
+ color: #FFF8E7 !important;
168
+ font-size: 2.8em !important;
169
+ font-weight: 700 !important;
170
+ margin: 0 !important;
171
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
172
+ }
173
+ #header-block h3 {
174
+ color: #FFE4B5 !important;
175
+ font-weight: 500 !important;
176
+ margin: 10px 0 0 0 !important;
177
+ }
178
+ #header-block p {
179
+ color: #FFF8E7 !important;
180
+ margin-top: 14px !important;
181
+ font-size: 1.05em !important;
182
+ opacity: 0.95;
183
+ }
184
+
185
+ /* ---------- TABS (the big upgrade) ---------- */
186
+ .tab-nav {
187
+ background: transparent !important;
188
+ border-bottom: none !important;
189
+ gap: 12px !important;
190
+ padding: 0 4px !important;
191
+ margin-bottom: 8px !important;
192
+ }
193
+
194
+ .tab-nav button {
195
+ background: #FFF8E7 !important;
196
+ border: 2px solid #D2B48C !important;
197
+ color: #8B4513 !important;
198
+ font-family: 'Nunito', sans-serif !important;
199
+ font-size: 1.15em !important;
200
+ font-weight: 700 !important;
201
+ padding: 14px 32px !important;
202
+ border-radius: 14px !important;
203
+ margin: 0 !important;
204
+ box-shadow: 0 2px 6px rgba(139, 69, 19, 0.12) !important;
205
+ transition: all 0.25s ease !important;
206
+ cursor: pointer !important;
207
+ }
208
+
209
+ .tab-nav button:hover {
210
+ background: #FFE8C8 !important;
211
+ border-color: #A0522D !important;
212
+ transform: translateY(-2px);
213
+ box-shadow: 0 4px 12px rgba(139, 69, 19, 0.25) !important;
214
+ }
215
+
216
+ .tab-nav button.selected {
217
+ background: linear-gradient(135deg, #8B4513 0%, #A0522D 100%) !important;
218
+ border-color: #8B4513 !important;
219
+ color: #FFF8E7 !important;
220
+ box-shadow: 0 6px 16px rgba(139, 69, 19, 0.4) !important;
221
+ transform: translateY(-2px);
222
+ }
223
+
224
+ /* ---------- BUTTONS ---------- */
225
+ button.primary, .gr-button-primary {
226
+ background: linear-gradient(135deg, #8B4513 0%, #A0522D 100%) !important;
227
+ border: none !important;
228
+ color: #FFF8E7 !important;
229
+ font-family: 'Nunito', sans-serif !important;
230
+ font-weight: 700 !important;
231
+ font-size: 1.08em !important;
232
+ padding: 14px 30px !important;
233
+ border-radius: 12px !important;
234
+ box-shadow: 0 4px 12px rgba(139, 69, 19, 0.3) !important;
235
+ transition: all 0.2s ease !important;
236
+ }
237
+ button.primary:hover, .gr-button-primary:hover {
238
+ transform: translateY(-2px);
239
+ box-shadow: 0 6px 16px rgba(139, 69, 19, 0.45) !important;
240
+ }
241
+
242
+ /* ---------- INPUTS / PANELS ---------- */
243
+ .gr-box, .gr-form, .gr-panel {
244
+ background: #FFF8E7 !important;
245
+ border: 2px solid #D2B48C !important;
246
+ border-radius: 14px !important;
247
+ }
248
+
249
+ label, .gr-input-label {
250
+ color: #5C4033 !important;
251
+ font-family: 'Nunito', sans-serif !important;
252
+ font-weight: 700 !important;
253
+ font-size: 1em !important;
254
+ }
255
+
256
+ textarea, input[type="text"] {
257
+ background: #FFFAF0 !important;
258
+ border: 2px solid #D2B48C !important;
259
+ color: #3E2723 !important;
260
+ font-family: 'Nunito', sans-serif !important;
261
+ font-size: 1.02em !important;
262
+ border-radius: 10px !important;
263
+ padding: 12px !important;
264
+ }
265
+
266
+ textarea:focus, input[type="text"]:focus {
267
+ border-color: #8B4513 !important;
268
+ outline: none !important;
269
+ box-shadow: 0 0 0 3px rgba(139, 69, 19, 0.15) !important;
270
+ }
271
+
272
+ .gr-gallery {
273
+ background: #FFF8E7 !important;
274
+ border: 2px solid #D2B48C !important;
275
+ border-radius: 14px !important;
276
+ padding: 10px !important;
277
+ }
278
+
279
+ /* ---------- FOOTER ---------- */
280
+ #footer-block {
281
+ margin-top: 28px;
282
+ padding: 22px 24px;
283
+ background: rgba(139, 69, 19, 0.08);
284
+ border-radius: 14px;
285
+ border-left: 5px solid #8B4513;
286
+ color: #5C4033 !important;
287
+ font-family: 'Nunito', sans-serif !important;
288
+ line-height: 1.7;
289
+ }
290
+ #footer-block a {
291
+ color: #8B4513 !important;
292
+ font-weight: 700;
293
+ text-decoration: none;
294
+ border-bottom: 1px dashed #8B4513;
295
+ }
296
+ #footer-block a:hover {
297
+ color: #A0522D !important;
298
+ }
299
+ """
300
+
301
+ # Build sample image indices defensively so a smaller dataset can't crash startup.
302
+ SAMPLE_IDX = [i for i in [50, 2000, 5000, 7500, 10000] if i < len(full_ds)]
303
+
304
+ with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft(
305
+ primary_hue="orange", secondary_hue="amber", neutral_hue="stone",
306
+ ), title="Wonder Finder") as demo:
307
+
308
+ gr.HTML("""
309
+ <div id="header-block">
310
+ <h1>🌍 Wonder Finder</h1>
311
+ <h3>Discover the World's 12 Wonders Through AI Vision</h3>
312
+ <p>Upload a travel photo or describe a place — get the closest matches from 11,544 images.<br>
313
+ Powered by CLIP's joint image–text embedding space.</p>
314
+ </div>
315
+ """)
316
+
317
+ with gr.Tabs():
318
+ with gr.Tab("📷 Search by Image"):
319
+ gr.Markdown("### Upload your travel photo, and we'll find the wonders that look most like it.")
320
+ with gr.Row():
321
+ with gr.Column(scale=1):
322
+ img_input = gr.Image(type="pil", label="Drop your photo here", height=320)
323
+ img_btn = gr.Button("✨ Find Similar Wonders", variant="primary", size="lg")
324
+ with gr.Column(scale=2):
325
+ img_gallery = gr.Gallery(label="Top 3 Matches", columns=3, rows=1, height=320, object_fit="cover")
326
+ img_summary = gr.Textbox(label="📊 Match Details", lines=6, show_copy_button=True)
327
+ if SAMPLE_IDX:
328
+ gr.Examples(
329
+ examples=[[full_ds[i]["image"]] for i in SAMPLE_IDX],
330
+ inputs=img_input,
331
+ label="✨ Or try these sample images:",
332
+ )
333
+ img_btn.click(recommend_from_image, inputs=img_input, outputs=[img_gallery, img_summary])
334
+
335
+ with gr.Tab("💬 Search by Description"):
336
+ gr.Markdown("### Describe a place in your own words — CLIP translates language into visual matches.")
337
+ with gr.Row():
338
+ with gr.Column(scale=1):
339
+ text_input = gr.Textbox(
340
+ label="Describe a wonder",
341
+ placeholder='e.g. "an ancient stone temple in the jungle" or "a tall tower at sunset"',
342
+ lines=3,
343
+ )
344
+ text_btn = gr.Button("✨ Find Matching Wonders", variant="primary", size="lg")
345
+ with gr.Column(scale=2):
346
+ text_gallery = gr.Gallery(label="Top 3 Matches", columns=3, rows=1, height=320, object_fit="cover")
347
+ text_summary = gr.Textbox(label="📊 Match Details", lines=6, show_copy_button=True)
348
+ gr.Examples(
349
+ examples=[
350
+ ["ancient stone pyramid in the desert"],
351
+ ["tall modern skyscraper at night"],
352
+ ["waterfall in the tropical jungle"],
353
+ ["ancient Roman amphitheater"],
354
+ ["statue of a religious figure with outstretched arms"],
355
+ ["a misty stone monument at sunrise"],
356
+ ["white marble palace with a dome"],
357
+ ],
358
+ inputs=text_input,
359
+ label="✨ Or try these example queries:",
360
+ )
361
+ text_btn.click(recommend_from_text, inputs=text_input, outputs=[text_gallery, text_summary])
362
+
363
+ gr.HTML("""
364
+ <div id="footer-block">
365
+ <strong>About this app</strong><br>
366
+ <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>
367
+ <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>
368
+ <strong>Method:</strong> L2-normalized cosine similarity over precomputed embeddings, with a diversity filter (threshold 0.98) to suppress near-duplicate results.
369
+ </div>
370
+ """)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ demo.launch()
requirements (1).txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.0
2
+ transformers==4.45.2
3
+ torch==2.4.1
4
+ datasets==3.0.0
5
+ pillow==10.4.0
6
+ numpy==1.26.4
7
+ pandas==2.2.2
8
+ pyarrow==17.0.0
9
+ huggingface-hub==0.25.0
wonders_embeddings.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ec4b31afc69b0b81a15b06640df8fa427f0a1eba2f2fa9984a36444b456fe8f5
3
+ size 36795559