""" WikiArt Visual Recommender — HuggingFace Space ================================================ Space URL : https://huggingface.co/spaces/Uris001/wikiart-art-recommender Model : openai/clip-vit-base-patch32 Dataset : Artificio/WikiArt (8,000 images embedded) """ import io import traceback import numpy as np import pandas as pd import torch import torch.nn.functional as F import gradio as gr from PIL import Image from transformers import CLIPProcessor, CLIPModel # ── Config ──────────────────────────────────────────────────────────────────── MODEL_ID = "openai/clip-vit-base-patch32" PARQUET = "wikiart_embeddings_with_images.parquet" TOP_K = 3 DEVICE = "cuda" if torch.cuda.is_available() else "cpu" # ── Load model at startup ───────────────────────────────────────────────────── print(f"Loading CLIP on {DEVICE} ...") model = CLIPModel.from_pretrained(MODEL_ID).to(DEVICE) processor = CLIPProcessor.from_pretrained(MODEL_ID) model.eval() print("Model ready.") # ── Load embedding index ────────────────────────────────────────────────────── print("Loading parquet ...") index_df = pd.read_parquet(PARQUET) emb_matrix = np.vstack([ np.array(e, dtype=np.float32) for e in index_df["embedding"].values ]) norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True) emb_matrix = emb_matrix / np.where(norms == 0, 1, norms) print(f"Index ready: {len(index_df):,} artworks | matrix: {emb_matrix.shape}") # ── Encode functions ────────────────────────────────────────────────────────── def encode_image(pil_img: Image.Image) -> np.ndarray: inp = processor(images=pil_img, return_tensors="pt") pixels = inp["pixel_values"].to(DEVICE) with torch.no_grad(): out = model.vision_model(pixel_values=pixels) projected = model.visual_projection(out.pooler_output) return F.normalize(projected, dim=-1).cpu().numpy().squeeze() def encode_text(text: str) -> np.ndarray: inp = processor(text=[text], return_tensors="pt", padding=True, truncation=True) ids = inp["input_ids"].to(DEVICE) mask = inp["attention_mask"].to(DEVICE) with torch.no_grad(): out = model.text_model(input_ids=ids, attention_mask=mask) projected = model.text_projection(out.pooler_output) return F.normalize(projected, dim=-1).cpu().numpy().squeeze() def encode_combined(pil_img: Image.Image, text: str) -> np.ndarray: img_vec = encode_image(pil_img) txt_vec = encode_text(text) combined = (img_vec + txt_vec) / 2.0 norm = np.linalg.norm(combined) return combined / norm if norm > 0 else combined # ── Helpers ─────────────────────────────────────────────────────────────────── def bytes_to_pil(b) -> Image.Image: return Image.open(io.BytesIO(bytes(b))).convert("RGB") def retrieve_top_k(query_vec: np.ndarray, k: int = TOP_K): scores = emb_matrix @ query_vec top_idx = np.argsort(scores)[::-1][:k] results = [] for idx in top_idx: row = index_df.iloc[int(idx)] results.append({ "rank" : len(results) + 1, "score" : float(scores[idx]), "title" : str(row.get("title", "Unknown")), "artist": str(row.get("artist", "Unknown")), "style" : str(row.get("style", "Unknown")), "genre" : str(row.get("genre", "Unknown")), "image" : bytes_to_pil(row["image_bytes"]), }) return results def clear_all(): return None, "", None, None, None, "" # ── Main recommend function ─────────────────────────────────────────────────── def recommend(image_input, text_input): try: has_image = image_input is not None has_text = isinstance(text_input, str) and text_input.strip() != "" if not has_image and not has_text: return None, None, None, "⚠️ Please upload an image or enter a text description." if has_image and has_text: pil_img = image_input if isinstance(image_input, Image.Image) else Image.fromarray(image_input) query_vec = encode_combined(pil_img, text_input.strip()) mode = "🔀 Image + Text (combined)" elif has_image: pil_img = image_input if isinstance(image_input, Image.Image) else Image.fromarray(image_input) query_vec = encode_image(pil_img) mode = "🖼 Image query" else: query_vec = encode_text(text_input.strip()) mode = "✍️ Text query" results = retrieve_top_k(query_vec, k=TOP_K) medals = ["🥇", "🥈", "🥉"] lines = [f"Query mode: {mode}\n{'─'*42}"] for r in results: bar_filled = int(r['score'] * 20) bar = "█" * bar_filled + "░" * (20 - bar_filled) lines.append( f"\n{medals[r['rank']-1]} Rank {r['rank']} | [{bar}] {r['score']:.4f}\n" f" Title : {r['title']}\n" f" Artist : {r['artist']}\n" f" Style : {r['style']}\n" f" Genre : {r['genre']}" ) details = "\n".join(lines) img1 = results[0]["image"] if len(results) > 0 else None img2 = results[1]["image"] if len(results) > 1 else None img3 = results[2]["image"] if len(results) > 2 else None return img1, img2, img3, details except Exception: err = traceback.format_exc() print(err) return None, None, None, f"Error:\n{err}" # ── Examples ────────────────────────────────────────────────────────────────── TEXT_EXAMPLES = [ ["bright colorful impressionist landscape with flowers and sunlight"], ["dark dramatic baroque religious painting with shadows and candlelight"], ["abstract geometric shapes in primary colors cubist style"], ["portrait of a woman with soft elegant brushwork renaissance"], ["japanese woodblock print with waves mountains and nature"], ["melting clocks surrealist dreamlike scene salvador dali style"], ] # ── CSS ─────────────────────────────────────────────────────────────────────── CSS = """ .gradio-container { background: linear-gradient(135deg, #0f0c29, #302b63, #24243e) !important; min-height: 100vh; } .hero-box { background: linear-gradient(120deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 32px 40px; margin-bottom: 24px; text-align: center; } .hero-box h1 { font-size: 2.4em !important; font-weight: 800 !important; background: linear-gradient(90deg, #a78bfa, #60a5fa, #34d399); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin-bottom: 8px !important; } .hero-box p { color: #94a3b8; font-size: 1.05em; line-height: 1.6; } .hero-box a { color: #a78bfa !important; text-decoration: none; } .stat-row { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; } .stat-chip { background: rgba(167,139,250,0.12); border: 1px solid rgba(167,139,250,0.3); border-radius: 20px; padding: 6px 16px; font-size: 0.82em; color: #c4b5fd; font-weight: 600; letter-spacing: 0.03em; } .input-panel { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 20px; } button.primary { background: linear-gradient(90deg, #7c3aed, #2563eb) !important; border: none !important; border-radius: 12px !important; font-size: 1.05em !important; font-weight: 700 !important; letter-spacing: 0.03em !important; transition: opacity 0.2s !important; } button.primary:hover { opacity: 0.88 !important; } .result-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; } .result-card:hover { border-color: rgba(167,139,250,0.4); } .details-box textarea { background: rgba(0,0,0,0.3) !important; color: #e2e8f0 !important; border: 1px solid rgba(255,255,255,0.1) !important; border-radius: 12px !important; font-family: 'JetBrains Mono', 'Fira Code', monospace !important; font-size: 0.88em !important; line-height: 1.7 !important; } .how-box { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 14px; padding: 18px 24px; } .how-box h3 { color: #c4b5fd; margin-bottom: 10px; } .how-box p { color: #94a3b8; font-size: 0.9em; line-height: 1.6; margin: 6px 0; } .bias-footer { background: rgba(239,68,68,0.06); border: 1px solid rgba(239,68,68,0.2); border-radius: 12px; padding: 12px 20px; color: #fca5a5; font-size: 0.85em; margin-top: 8px; } """ # ── Gradio UI ───────────────────────────────────────────────────────────────── with gr.Blocks(title="WikiArt Visual Recommender", css=CSS) as demo: # ── Hero ────────────────────────────────────────────────────────────────── gr.HTML("""
Upload a painting and/or describe what you want — discover the 3 most visually similar artworks from 8,000 WikiArt masterpieces.
5-minute walkthrough · Dataset selection → EDA → CLIP Embeddings → Clustering → Live Demo
① Your image or text → CLIP → 512-D vector
② Cosine similarity vs 8,000 pre-embedded artworks
③ Top 3 closest artworks returned with metadata
🔀 Provide both inputs to combine visual + semantic search