irbg bebechien commited on
Commit
a18a820
·
0 Parent(s):

Super-squash branch 'main' using huggingface_hub

Browse files

Co-authored-by: bebechien <bebechien@users.noreply.huggingface.co>

Files changed (4) hide show
  1. .gitattributes +35 -0
  2. README.md +15 -0
  3. app.py +982 -0
  4. requirements.txt +10 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Embedding Draw Challenge
3
+ emoji: 🖌️
4
+ colorFrom: gray
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 6.28.0
8
+ python_version: '3.12'
9
+ app_file: app.py
10
+ pinned: false
11
+ license: apache-2.0
12
+ short_description: Draw Challenge with EmbeddingGemma 2
13
+ ---
14
+
15
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import subprocess
4
+
5
+ from huggingface_hub import hf_hub_download
6
+
7
+ token = os.environ.get("HF_TOKEN")
8
+
9
+ transformers_wheel = hf_hub_download(
10
+ repo_id="gg-hf-em/embeddinggemma-2-eap-extras",
11
+ filename="transformers-5.18.0.dev0-py3-none-any.whl",
12
+ repo_type="dataset",
13
+ token=token
14
+ )
15
+
16
+ # Install custom local wheels
17
+ subprocess.run([sys.executable, "-m", "pip", "install", transformers_wheel], check=False)
18
+
19
+ try:
20
+ import spaces
21
+ except ImportError:
22
+ class _SpacesFallback:
23
+ @staticmethod
24
+ def GPU(*args, **kwargs):
25
+ def _decorator(fn):
26
+ return fn
27
+ if len(args) == 1 and callable(args[0]) and not kwargs:
28
+ return args[0]
29
+ return _decorator
30
+ spaces = _SpacesFallback()
31
+
32
+ import base64
33
+ import io
34
+ import inspect
35
+ import time
36
+ from typing import Dict, List, Any, Tuple
37
+
38
+ import gradio as gr
39
+ import numpy as np
40
+ import torch
41
+ from PIL import Image
42
+ from sentence_transformers import SentenceTransformer
43
+
44
+ # Optimize CPU threading for low-latency inference
45
+ torch.set_num_threads(min(16, os.cpu_count() or 8))
46
+
47
+ # Curated Theme & Object Database from GDD Section 3
48
+ THEME_DATABASE: Dict[str, Dict[str, Any]] = {
49
+ "Fruit": {
50
+ "target": "Banana",
51
+ "distractors": [
52
+ "Apple",
53
+ "Strawberry",
54
+ "Orange",
55
+ "Watermelon",
56
+ "Pineapple",
57
+ "Grapes",
58
+ "Cherries",
59
+ ],
60
+ "icon": "🍌",
61
+ "description": "Draw organic silhouettes and vibrant fruit hues.",
62
+ "hints": {
63
+ "Banana": "Emphasize the curved crescent silhouette and bright yellow hue (#FFE135) with brown tips.",
64
+ "Apple": "Draw a rounded red or green fruit body with a top indent and small leaf.",
65
+ "Strawberry": "Sketch a tapered heart/cone shape in bright red with green leafy cap and seeds.",
66
+ "Orange": "Draw a round citrus sphere in bright orange (#F97316) with subtle texture.",
67
+ "Watermelon": "Draw a green rind slice or wedge with bright red flesh and black seeds.",
68
+ "Pineapple": "Sketch an oval golden-yellow body with crosshatch diamond lines and a spiky green crown.",
69
+ "Grapes": "Draw a clustered bunch of round purple or green berries hanging from a vine stem.",
70
+ "Cherries": "Sketch a pair of glossy bright red round berries joined at the top by twin green stems.",
71
+ },
72
+ },
73
+ "Vehicle": {
74
+ "target": "Sports Car",
75
+ "distractors": [
76
+ "Bicycle",
77
+ "Airplane",
78
+ "Bus",
79
+ "Sailboat",
80
+ "Helicopter",
81
+ "Train",
82
+ "Rocket",
83
+ ],
84
+ "icon": "🏎️",
85
+ "description": "Capture mechanical geometry, wheels, and aerodynamic profiles.",
86
+ "hints": {
87
+ "Sports Car": "Highlight a low aerodynamic chassis in red, sloped windshield, and two distinct wheels.",
88
+ "Bicycle": "Draw two spoked wheels connected by a triangular diamond frame and handlebars.",
89
+ "Airplane": "Sketch a central fuselage tube with swept wings and a tail fin.",
90
+ "Bus": "Draw a long rectangular body with a row of passenger windows and wheels.",
91
+ "Sailboat": "Draw a curved boat hull floating on blue waves with a tall mast and triangular sails.",
92
+ "Helicopter": "Sketch a rounded cockpit cabin with wide horizontal top rotor blades and a tail boom.",
93
+ "Train": "Draw a locomotive engine on rails with multiple wheels, a cab, smokestack, and front grill.",
94
+ "Rocket": "Sketch a vertical cylindrical body with a pointed nose cone, round window, fins, and orange exhaust flame.",
95
+ },
96
+ },
97
+ "Animal": {
98
+ "target": "Elephant",
99
+ "distractors": [
100
+ "Giraffe",
101
+ "Lion",
102
+ "Cat",
103
+ "Penguin",
104
+ "Butterfly",
105
+ "Turtle",
106
+ "Rabbit",
107
+ ],
108
+ "icon": "🐘",
109
+ "description": "Sketch distinctive anatomical traits: trunks, ears, necks, and manes.",
110
+ "hints": {
111
+ "Elephant": "Focus on the large grey body, long curved trunk, wide floppy ear, and sturdy legs.",
112
+ "Giraffe": "Emphasize an elongated vertical neck, spotted pattern, and long slender legs.",
113
+ "Lion": "Draw a bold circular mane surrounding a feline face and golden body.",
114
+ "Cat": "Sketch pointed triangular ears, whiskers, a curved tail, and agile feline silhouette.",
115
+ "Penguin": "Draw an upright black body with a white belly oval, orange beak, side flippers, and orange feet.",
116
+ "Butterfly": "Sketch large symmetrical colorful wings extending from a slender central body with two antennae.",
117
+ "Turtle": "Draw a rounded dome shell with hexagonal patterns, four stubby legs, and an outstretched head.",
118
+ "Rabbit": "Sketch a compact body with two long upright ears, a round nose, whiskers, and a fluffy tail.",
119
+ },
120
+ },
121
+ "Country (Shape/Icon)": {
122
+ "target": "Korea",
123
+ "distractors": [
124
+ "Japan",
125
+ "Brazil",
126
+ "Australia",
127
+ "Canada",
128
+ "Italy",
129
+ "United States",
130
+ "France",
131
+ ],
132
+ "icon": "🇰🇷",
133
+ "description": "Draw iconic national symbols, flags, or geographic silhouettes.",
134
+ "hints": {
135
+ "Korea": "Draw the iconic red/blue Taegeuk swirl circle with black corner trigrams (Taegeukgi) or peninsula shape.",
136
+ "Japan": "Draw a crisp red sun disc centered on a white rectangular flag canvas.",
137
+ "Brazil": "Sketch a green flag with a yellow diamond and blue celestial globe in the center.",
138
+ "Australia": "Draw the distinctive Southern Cross stars, Union Jack corner, or continental island outline.",
139
+ "Canada": "Draw a white flag center flanked by vertical red bars with a bold red maple leaf in the middle.",
140
+ "Italy": "Sketch the vertical green, white, and red tricolor flag or the iconic boot-shaped peninsula.",
141
+ "United States": "Draw horizontal red and white stripes with a blue top-left canton containing white stars.",
142
+ "France": "Sketch the vertical blue, white, and red tricolor flag or the iconic Eiffel Tower silhouette.",
143
+ },
144
+ },
145
+ }
146
+
147
+
148
+ class EmbeddingDrawEngine:
149
+ """Multimodal vector embedding engine powered by SentenceTransformers & EmbeddingGemma 2."""
150
+
151
+ def __init__(self, model_path: str = "./embeddinggemma-2", cache_path: str = ".cache/reference_vectors.npz"):
152
+ print(f"[EmbeddingEngine] Initializing SentenceTransformer from {model_path}...")
153
+ t0 = time.time()
154
+ if not os.path.exists(model_path):
155
+ model_path = "gg-hf-em/embeddinggemma-2"
156
+
157
+ self.cache_path = cache_path
158
+ device = "cuda" if torch.cuda.is_available() else "cpu"
159
+ self.model = SentenceTransformer(
160
+ model_path,
161
+ truncate_dim=128,
162
+ similarity_fn_name="dot",
163
+ device=device,
164
+ config_kwargs={"audio_config": None},
165
+ )
166
+
167
+ # Optimize image processor token length for real-time stroke evaluation (< 0.5s latency)
168
+ if hasattr(self.model[0], "processor") and hasattr(self.model[0].processor, "image_processor"):
169
+ self.model[0].processor.image_processor.max_soft_tokens = 70
170
+ self.model[0].processor.image_processor.image_seq_length = 70
171
+
172
+ # Hook to capture authentic pre-normalization L2 vector norm from Pooling layer
173
+ self._last_raw_norm: float = 479.69
174
+ if len(self.model) > 1:
175
+ self.model[1].register_forward_hook(self._capture_norm_hook)
176
+
177
+ # Collect all unique objects across themes
178
+ all_objects = set()
179
+ for theme_data in THEME_DATABASE.values():
180
+ all_objects.add(theme_data["target"])
181
+ all_objects.update(theme_data["distractors"])
182
+ self.sorted_objects: List[str] = sorted(list(all_objects))
183
+
184
+ # Initialize attributes in main process so they always exist even before first GPU call
185
+ self.text_embeddings: Dict[str, np.ndarray] = {}
186
+ self.blank_embedding: np.ndarray | None = None
187
+ self.blank_similarities: Dict[str, float] = {obj: 0.6100 for obj in self.sorted_objects}
188
+
189
+ print(f"[EmbeddingEngine] Model initialized in {time.time() - t0:.2f}s (device={self.model.device})")
190
+
191
+ def _compute_reference_vectors(self) -> Dict[str, Any]:
192
+ """Encode reference text prompts and blank canvas baseline image."""
193
+ prompts = [f"A simple drawing of {obj}" for obj in self.sorted_objects]
194
+ encoded_texts = self.model.encode(prompts, normalize_embeddings=True)
195
+ text_embeddings = {
196
+ obj: np.array(emb, dtype=np.float32)
197
+ for obj, emb in zip(self.sorted_objects, encoded_texts)
198
+ }
199
+
200
+ blank_img = Image.new("RGB", (240, 240), color=(255, 255, 255))
201
+ blank_embedding = np.array(
202
+ self.model.encode({"image": blank_img}, normalize_embeddings=True),
203
+ dtype=np.float32,
204
+ )
205
+ raw_norm = float(self._last_raw_norm)
206
+
207
+ blank_similarities = {
208
+ obj: float(np.dot(blank_embedding, text_emb))
209
+ for obj, text_emb in text_embeddings.items()
210
+ }
211
+
212
+ return {
213
+ "text_embeddings": text_embeddings,
214
+ "blank_embedding": blank_embedding,
215
+ "blank_similarities": blank_similarities,
216
+ "raw_norm": raw_norm,
217
+ }
218
+
219
+ def _load_cache_from_disk(self) -> bool:
220
+ """Attempt to load precomputed reference vectors from disk cache."""
221
+ if not self.cache_path or not os.path.exists(self.cache_path):
222
+ return False
223
+ try:
224
+ with np.load(self.cache_path) as data:
225
+ if "blank_embedding" not in data or "blank_raw_norm" not in data:
226
+ return False
227
+ loaded_text_embs: Dict[str, np.ndarray] = {}
228
+ for obj in self.sorted_objects:
229
+ key = f"text_emb_{obj}"
230
+ if key not in data:
231
+ return False
232
+ loaded_text_embs[obj] = np.array(data[key], dtype=np.float32)
233
+
234
+ self.blank_embedding = np.array(data["blank_embedding"], dtype=np.float32)
235
+ self._last_raw_norm = float(data["blank_raw_norm"])
236
+ self.text_embeddings = loaded_text_embs
237
+ self.blank_similarities = {
238
+ obj: float(np.dot(self.blank_embedding, text_emb))
239
+ for obj, text_emb in self.text_embeddings.items()
240
+ }
241
+ print(f"[EmbeddingEngine] Loaded cached reference vectors from {self.cache_path}")
242
+ return True
243
+ except Exception as e:
244
+ print(f"[EmbeddingEngine] Failed to load reference cache ({e}), rebuilding...")
245
+ return False
246
+
247
+ def _save_cache_to_disk(self) -> None:
248
+ """Save computed reference vectors to disk cache."""
249
+ if not self.cache_path or self.blank_embedding is None or not self.text_embeddings:
250
+ return
251
+ try:
252
+ cache_dir = os.path.dirname(self.cache_path)
253
+ if cache_dir:
254
+ os.makedirs(cache_dir, exist_ok=True)
255
+ save_dict: Dict[str, Any] = {
256
+ "blank_embedding": self.blank_embedding,
257
+ "blank_raw_norm": np.array(self._last_raw_norm, dtype=np.float32),
258
+ }
259
+ for obj, emb in self.text_embeddings.items():
260
+ save_dict[f"text_emb_{obj}"] = emb
261
+ np.savez(self.cache_path, **save_dict)
262
+ print(f"[EmbeddingEngine] Saved reference vectors cache to {self.cache_path}")
263
+ except Exception as e:
264
+ print(f"[EmbeddingEngine] Warning: could not write cache file {self.cache_path}: {e}")
265
+
266
+ def ensure_reference_embeddings(self) -> None:
267
+ """Build and cache reference text embeddings and blank canvas baseline in memory and on disk."""
268
+ if self.text_embeddings and self.blank_embedding is not None:
269
+ return
270
+
271
+ if self._load_cache_from_disk():
272
+ return
273
+
274
+ t0 = time.time()
275
+ print(f"[EmbeddingEngine] Building reference vectors for {len(self.sorted_objects)} objects...")
276
+ try:
277
+ res = _gpu_build_reference_embeddings()
278
+ except Exception:
279
+ res = self._compute_reference_vectors()
280
+
281
+ self.text_embeddings = res["text_embeddings"]
282
+ self.blank_embedding = res["blank_embedding"]
283
+ self.blank_similarities = res["blank_similarities"]
284
+ self._last_raw_norm = float(res["raw_norm"])
285
+
286
+ self._save_cache_to_disk()
287
+ print(f"[EmbeddingEngine] Reference vectors built and cached in {time.time() - t0:.2f}s")
288
+
289
+ def _capture_norm_hook(self, module, inputs, output):
290
+ if hasattr(output, "__getitem__") and "sentence_embedding" in output:
291
+ tensor = output["sentence_embedding"]
292
+ self._last_raw_norm = float(torch.norm(tensor, p=2, dim=-1).mean().item())
293
+
294
+ @staticmethod
295
+ def compute_ink_ratio(img: Image.Image) -> float:
296
+ """Compute fraction of non-white pixels on canvas to detect blank or near-blank state."""
297
+ gray = img.convert("L")
298
+ arr = np.array(gray, dtype=np.uint8)
299
+ non_white = np.sum(arr < 248)
300
+ return float(non_white) / float(arr.size)
301
+
302
+ def evaluate_drawing(
303
+ self,
304
+ image_input: Image.Image | str | None,
305
+ theme_name: str,
306
+ target_object: str,
307
+ ) -> Dict[str, Any]:
308
+ """Evaluate canvas image against target and comparing objects using Cosine Similarity."""
309
+ t_start = time.time()
310
+
311
+ if theme_name not in THEME_DATABASE:
312
+ theme_name = "Fruit"
313
+ theme_info = THEME_DATABASE[theme_name]
314
+ all_theme_objects = [theme_info["target"]] + theme_info["distractors"]
315
+ if target_object not in all_theme_objects:
316
+ target_object = theme_info["target"]
317
+ distractors = [o for o in all_theme_objects if o != target_object]
318
+ ordered_objects = [target_object] + distractors
319
+
320
+ if image_input is None:
321
+ ink_ratio = 0.0
322
+ resized_img = None
323
+ else:
324
+ if isinstance(image_input, str):
325
+ if "," in image_input:
326
+ image_input = image_input.split(",", 1)[1]
327
+ img_bytes = base64.b64decode(image_input)
328
+ raw_img = Image.open(io.BytesIO(img_bytes))
329
+ else:
330
+ raw_img = image_input
331
+
332
+ # Composite onto solid white RGB canvas
333
+ rgb_img = Image.new("RGB", raw_img.size, (255, 255, 255))
334
+ if raw_img.mode in ("RGBA", "LA") or (raw_img.mode == "P" and "transparency" in raw_img.info):
335
+ rgba = raw_img.convert("RGBA")
336
+ rgb_img.paste(rgba, mask=rgba.split()[3])
337
+ else:
338
+ rgb_img.paste(raw_img.convert("RGB"))
339
+
340
+ resized_img = rgb_img.resize((240, 240), Image.Resampling.BILINEAR)
341
+ ink_ratio = self.compute_ink_ratio(resized_img)
342
+
343
+ # If canvas is blank or virtually blank (< 0.05% ink), return clean zero state immediately without GPU call
344
+ if resized_img is None or ink_ratio < 0.0005:
345
+ latency_ms = int((time.time() - t_start) * 1000)
346
+ scores_list = [
347
+ {
348
+ "name": obj,
349
+ "is_target": (obj == target_object),
350
+ "percentage": 0.0,
351
+ "raw_cosine": round(self.blank_similarities.get(obj, 0.61), 4),
352
+ "delta": 0.0,
353
+ }
354
+ for obj in ordered_objects
355
+ ]
356
+ return {
357
+ "theme": theme_name,
358
+ "target": target_object,
359
+ "scores": scores_list,
360
+ "target_score": 0.0,
361
+ "win": False,
362
+ "telemetry": {
363
+ "loss": 1.3863,
364
+ "norm": round(self._last_raw_norm, 2),
365
+ "cosine": round(self.blank_similarities.get(target_object, 0.61), 4),
366
+ "latency_ms": latency_ms,
367
+ },
368
+ "insight": f"Canvas ready. Start drawing **{target_object}**! {theme_info['hints'].get(target_object, '')}",
369
+ }
370
+
371
+ # Execute multimodal embedding inference on ZeroGPU worker
372
+ gpu_result = _gpu_run_inference(resized_img, ordered_objects)
373
+ self.blank_similarities.update(gpu_result["all_blank_similarities"])
374
+ raw_norm = float(gpu_result["raw_norm"])
375
+ self._last_raw_norm = raw_norm
376
+
377
+ # Calculate Cosine Similarities and baseline deltas
378
+ raw_cosines = np.array(gpu_result["raw_cosines"], dtype=np.float32)
379
+ blank_cosines = np.array(gpu_result["blank_cosines"], dtype=np.float32)
380
+ deltas = raw_cosines - blank_cosines
381
+
382
+ # Contrastive Cross-Entropy Loss telemetry (temperature tau = 0.025)
383
+ tau = 0.025
384
+ scaled_deltas = deltas / tau
385
+ exp_deltas = np.exp(scaled_deltas - np.max(scaled_deltas))
386
+ softmax_probs = exp_deltas / np.sum(exp_deltas)
387
+ contrastive_loss = float(-np.log(max(softmax_probs[0], 1e-7)))
388
+
389
+ # Map cosine similarity & relative semantic dominance to 0-100% scale
390
+ c_delta = deltas - np.mean(deltas)
391
+ c_raw = raw_cosines - np.mean(raw_cosines)
392
+ combined_signal = 0.65 * c_delta + 0.35 * c_raw
393
+
394
+ logits = combined_signal / max(float(np.std(combined_signal)), 0.005) * 1.75
395
+ exp_l = np.exp(logits - np.max(logits))
396
+ probs = exp_l / np.sum(exp_l)
397
+
398
+ # Positive gain boost over blank canvas
399
+ gain_boost = np.clip((deltas - np.min(deltas)) / max(float(np.ptp(deltas)), 0.012), 0.0, 1.0)
400
+ # Direct absolute delta reward so strong semantic matches reach 92-98%
401
+ abs_gain_bonus = np.clip(deltas / 0.045, -0.2, 1.0) * 18.0
402
+
403
+ pcts = 14.0 + 54.0 * probs + 22.0 * gain_boost + abs_gain_bonus
404
+
405
+ # Smooth ink warm-up ramp so single tiny dots don't spike to 90%
406
+ ink_warmup = min(1.0, ink_ratio / 0.006)
407
+ pcts = pcts * ink_warmup
408
+ pcts = np.clip(pcts, 1.0, 99.2)
409
+
410
+ scores_list = []
411
+ for idx, obj in enumerate(ordered_objects):
412
+ scores_list.append(
413
+ {
414
+ "name": obj,
415
+ "is_target": (idx == 0),
416
+ "percentage": round(float(pcts[idx]), 1),
417
+ "raw_cosine": round(float(raw_cosines[idx]), 4),
418
+ "delta": round(float(deltas[idx]), 4),
419
+ }
420
+ )
421
+
422
+ target_score = scores_list[0]["percentage"]
423
+ is_win = bool(target_score >= 90.0)
424
+
425
+ # Determine dynamic Insight message
426
+ best_distractor = max(scores_list[1:], key=lambda x: x["percentage"])
427
+ hint_text = theme_info["hints"].get(target_object, "Keep refining your drawing!")
428
+
429
+ if is_win:
430
+ insight_msg = (
431
+ f"🎯 Semantic Lock Achieved ({target_score:.1f}%)! EmbeddingGemma 2 strongly aligns your sketch "
432
+ f"with 'A simple drawing of {target_object}'."
433
+ )
434
+ elif best_distractor["percentage"] > target_score + 5.0:
435
+ insight_msg = (
436
+ f"⚠️ Vector Drift Alert: Your sketch currently resembles **{best_distractor['name']}** "
437
+ f"({best_distractor['percentage']:.1f}%) more than **{target_object}** ({target_score:.1f}%). "
438
+ f"Tip: {hint_text}"
439
+ )
440
+ elif target_score >= 70.0:
441
+ insight_msg = (
442
+ f"🔥 High Semantic Alignment ({target_score:.1f}%)! You are very close to the 90% threshold. "
443
+ f"Add a few more defining details or color fill to lock in **{target_object}**!"
444
+ )
445
+ elif target_score >= 40.0:
446
+ insight_msg = (
447
+ f"📈 Promising Trajectory ({target_score:.1f}%). **{target_object}** is emerging in the embedding space. "
448
+ f"Tip: {hint_text}"
449
+ )
450
+ else:
451
+ insight_msg = f"✏️ Keep drawing! {hint_text}"
452
+
453
+ latency_ms = int((time.time() - t_start) * 1000)
454
+
455
+ return {
456
+ "theme": theme_name,
457
+ "target": target_object,
458
+ "scores": scores_list,
459
+ "target_score": target_score,
460
+ "win": is_win,
461
+ "telemetry": {
462
+ "loss": round(contrastive_loss, 4),
463
+ "norm": round(raw_norm, 2),
464
+ "cosine": round(float(raw_cosines[0]), 4),
465
+ "latency_ms": latency_ms,
466
+ },
467
+ "insight": insight_msg,
468
+ }
469
+
470
+
471
+ # Instantiate global EmbeddingEngine singleton
472
+ ENGINE = EmbeddingDrawEngine()
473
+
474
+
475
+ @spaces.GPU(duration=60)
476
+ def _gpu_build_reference_embeddings() -> Dict[str, Any]:
477
+ """Standalone ZeroGPU worker task: builds reference text and blank canvas embeddings on GPU."""
478
+ return ENGINE._compute_reference_vectors()
479
+
480
+
481
+ @spaces.GPU(duration=60)
482
+ def _gpu_run_inference(resized_img: Image.Image, ordered_objects: List[str]) -> Dict[str, Any]:
483
+ """Standalone ZeroGPU worker task: encodes player drawing and computes cosines on GPU."""
484
+ ENGINE.ensure_reference_embeddings()
485
+
486
+ player_emb = np.array(ENGINE.model.encode({"image": resized_img}, normalize_embeddings=True), dtype=np.float32)
487
+ raw_norm = float(ENGINE._last_raw_norm)
488
+
489
+ raw_cosines = [float(np.dot(player_emb, ENGINE.text_embeddings[o])) for o in ordered_objects]
490
+ blank_cosines = [float(ENGINE.blank_similarities[o]) for o in ordered_objects]
491
+
492
+ return {
493
+ "raw_cosines": raw_cosines,
494
+ "blank_cosines": blank_cosines,
495
+ "raw_norm": raw_norm,
496
+ "all_blank_similarities": dict(ENGINE.blank_similarities),
497
+ }
498
+
499
+
500
+ def extract_pil_image(sketch_data: Any) -> Image.Image | None:
501
+ """Extract composite PIL Image from Gradio Sketchpad output."""
502
+ if sketch_data is None:
503
+ return None
504
+ if isinstance(sketch_data, Image.Image):
505
+ return sketch_data
506
+ if isinstance(sketch_data, dict):
507
+ img = sketch_data.get("composite") or sketch_data.get("background")
508
+ if isinstance(img, Image.Image):
509
+ return img
510
+ if isinstance(img, np.ndarray):
511
+ return Image.fromarray(img)
512
+ return None
513
+ if isinstance(sketch_data, np.ndarray):
514
+ return Image.fromarray(sketch_data)
515
+ return None
516
+
517
+
518
+ def get_theme_objects(theme_name: str) -> List[str]:
519
+ info = THEME_DATABASE.get(theme_name, THEME_DATABASE["Fruit"])
520
+ return [info["target"]] + info["distractors"]
521
+
522
+
523
+ def format_scoreboard(result: Dict[str, Any]) -> Dict[str, float]:
524
+ scores = result.get("scores", [])
525
+ if not scores or max(round(item["percentage"]) for item in scores) <= 0:
526
+ return {}
527
+ return {
528
+ f"🎯 {item['name'].upper()} (TARGET)" if item["is_target"] else item["name"]: round(item["percentage"] / 100.0, 4)
529
+ for item in scores
530
+ }
531
+
532
+
533
+ def format_status_message(result: Dict[str, Any]) -> str:
534
+ if result.get("win", False):
535
+ return (
536
+ f"### 🎉 CHALLENGE CLEAR! ({result['target_score']:.1f}%)\n"
537
+ f"You successfully captured the essence of **{result['target']}**! "
538
+ f"The vector embedding recognizes your artistic intent.\n\n"
539
+ f"{result['insight']}"
540
+ )
541
+ return f"### 💡 Hint & Status\n{result['insight']}"
542
+
543
+
544
+ SCOREBOARD_TARGET_CSS = """
545
+ <style>
546
+ /* Highlight the Target Object row in the Similarity Scoreboard (light & dark mode) */
547
+ .confidence-set[data-testid^="🎯"] {
548
+ --stat-background-fill: #10B981 !important;
549
+ background: rgba(16, 185, 129, 0.10) !important;
550
+ border: 1.5px solid rgba(16, 185, 129, 0.45) !important;
551
+ border-radius: 10px !important;
552
+ padding: 8px 12px !important;
553
+ margin-bottom: 10px !important;
554
+ box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12) !important;
555
+ }
556
+ .confidence-set[data-testid^="🎯"] .bar {
557
+ height: 8px !important;
558
+ background: #10B981 !important;
559
+ }
560
+ .confidence-set[data-testid^="🎯"] .text,
561
+ .confidence-set[data-testid^="🎯"] .confidence {
562
+ font-weight: 800 !important;
563
+ color: #047857 !important;
564
+ font-size: 1.04em !important;
565
+ }
566
+ .confidence-set[data-testid^="🎯"] .line {
567
+ border-color: rgba(16, 185, 129, 0.45) !important;
568
+ }
569
+ @media (prefers-color-scheme: dark) {
570
+ :root:not(.light) .confidence-set[data-testid^="🎯"],
571
+ body:not(.light) .confidence-set[data-testid^="🎯"] {
572
+ background: rgba(16, 185, 129, 0.18) !important;
573
+ border-color: rgba(52, 211, 153, 0.55) !important;
574
+ box-shadow: 0 2px 12px rgba(16, 185, 129, 0.22) !important;
575
+ }
576
+ :root:not(.light) .confidence-set[data-testid^="🎯"] .text,
577
+ :root:not(.light) .confidence-set[data-testid^="🎯"] .confidence,
578
+ body:not(.light) .confidence-set[data-testid^="🎯"] .text,
579
+ body:not(.light) .confidence-set[data-testid^="🎯"] .confidence {
580
+ color: #34D399 !important;
581
+ }
582
+ }
583
+ .dark .confidence-set[data-testid^="🎯"],
584
+ :root.dark .confidence-set[data-testid^="🎯"],
585
+ body.dark .confidence-set[data-testid^="🎯"] {
586
+ background: rgba(16, 185, 129, 0.18) !important;
587
+ border-color: rgba(52, 211, 153, 0.55) !important;
588
+ box-shadow: 0 2px 12px rgba(16, 185, 129, 0.22) !important;
589
+ }
590
+ .dark .confidence-set[data-testid^="🎯"] .text,
591
+ .dark .confidence-set[data-testid^="🎯"] .confidence,
592
+ :root.dark .confidence-set[data-testid^="🎯"] .text,
593
+ :root.dark .confidence-set[data-testid^="🎯"] .confidence,
594
+ body.dark .confidence-set[data-testid^="🎯"] .text,
595
+ body.dark .confidence-set[data-testid^="🎯"] .confidence {
596
+ color: #34D399 !important;
597
+ }
598
+ </style>
599
+ """
600
+
601
+
602
+ def format_popup_dialog(result: Dict[str, Any]) -> str:
603
+ """Render a celebratory 'Challenge Clear' popup dialog (supporting light & dark mode) when similarity reaches >= 90%."""
604
+ if not result.get("win", False):
605
+ return SCOREBOARD_TARGET_CSS
606
+
607
+ target = result.get("target", "Target")
608
+ score = float(result.get("target_score", 90.0))
609
+ cosine = result.get("telemetry", {}).get("cosine", 0.0)
610
+ ts = time.time_ns()
611
+
612
+ return SCOREBOARD_TARGET_CSS + f"""
613
+ <style>
614
+ @keyframes ccOverlayFadeIn {{
615
+ from {{ opacity: 0; }}
616
+ to {{ opacity: 1; }}
617
+ }}
618
+ @keyframes ccModalPopIn {{
619
+ 0% {{ opacity: 0; transform: scale(0.88) translateY(16px); }}
620
+ 70% {{ transform: scale(1.02) translateY(-2px); }}
621
+ 100% {{ opacity: 1; transform: scale(1) translateY(0); }}
622
+ }}
623
+ #challenge-clear-overlay {{
624
+ --cc-overlay-bg: rgba(15, 23, 42, 0.60);
625
+ --cc-card-bg: var(--block-background-fill, #FFFFFF);
626
+ --cc-card-border: rgba(16, 185, 129, 0.30);
627
+ --cc-card-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.28);
628
+ --cc-text-primary: var(--body-text-color, #0F172A);
629
+ --cc-text-secondary: #475569;
630
+ --cc-text-muted: #64748B;
631
+ --cc-surface-bg: var(--background-fill-secondary, #F8FAFC);
632
+ --cc-surface-border: var(--border-color-primary, #E2E8F0);
633
+ --cc-ring-track: #E2E8F0;
634
+ --cc-accent: #10B981;
635
+ --cc-accent-hover: #059669;
636
+ --cc-accent-text: #059669;
637
+ --cc-badge-bg: #ECFDF5;
638
+ --cc-badge-text: #047857;
639
+ --cc-badge-border: rgba(16, 185, 129, 0.25);
640
+ --cc-btn-sec-bg: #F1F5F9;
641
+ --cc-btn-sec-bg-hover: #E2E8F0;
642
+ --cc-btn-sec-text: #1E293B;
643
+ --cc-btn-sec-border: #CBD5E1;
644
+ --cc-close-bg: #F1F5F9;
645
+ --cc-close-bg-hover: #E2E8F0;
646
+ --cc-close-text: #64748B;
647
+ --cc-ghost-hover-bg: #F1F5F9;
648
+ }}
649
+ @media (prefers-color-scheme: dark) {{
650
+ :root:not(.light) #challenge-clear-overlay,
651
+ body:not(.light) #challenge-clear-overlay {{
652
+ --cc-overlay-bg: rgba(2, 6, 23, 0.78);
653
+ --cc-card-bg: var(--block-background-fill, #1E293B);
654
+ --cc-card-border: rgba(52, 211, 153, 0.35);
655
+ --cc-card-shadow: 0 25px 55px -12px rgba(0, 0, 0, 0.70);
656
+ --cc-text-primary: var(--body-text-color, #F8FAFC);
657
+ --cc-text-secondary: #CBD5E1;
658
+ --cc-text-muted: #94A3B8;
659
+ --cc-surface-bg: var(--background-fill-secondary, #0F172A);
660
+ --cc-surface-border: var(--border-color-primary, #334155);
661
+ --cc-ring-track: #334155;
662
+ --cc-accent: #10B981;
663
+ --cc-accent-hover: #059669;
664
+ --cc-accent-text: #34D399;
665
+ --cc-badge-bg: rgba(16, 185, 129, 0.16);
666
+ --cc-badge-text: #6EE7B7;
667
+ --cc-badge-border: rgba(52, 211, 153, 0.30);
668
+ --cc-btn-sec-bg: #334155;
669
+ --cc-btn-sec-bg-hover: #475569;
670
+ --cc-btn-sec-text: #F8FAFC;
671
+ --cc-btn-sec-border: #475569;
672
+ --cc-close-bg: #334155;
673
+ --cc-close-bg-hover: #475569;
674
+ --cc-close-text: #CBD5E1;
675
+ --cc-ghost-hover-bg: rgba(255, 255, 255, 0.08);
676
+ }}
677
+ }}
678
+ .dark #challenge-clear-overlay,
679
+ :root.dark #challenge-clear-overlay,
680
+ body.dark #challenge-clear-overlay {{
681
+ --cc-overlay-bg: rgba(2, 6, 23, 0.78);
682
+ --cc-card-bg: var(--block-background-fill, #1E293B);
683
+ --cc-card-border: rgba(52, 211, 153, 0.35);
684
+ --cc-card-shadow: 0 25px 55px -12px rgba(0, 0, 0, 0.70);
685
+ --cc-text-primary: var(--body-text-color, #F8FAFC);
686
+ --cc-text-secondary: #CBD5E1;
687
+ --cc-text-muted: #94A3B8;
688
+ --cc-surface-bg: var(--background-fill-secondary, #0F172A);
689
+ --cc-surface-border: var(--border-color-primary, #334155);
690
+ --cc-ring-track: #334155;
691
+ --cc-accent: #10B981;
692
+ --cc-accent-hover: #059669;
693
+ --cc-accent-text: #34D399;
694
+ --cc-badge-bg: rgba(16, 185, 129, 0.16);
695
+ --cc-badge-text: #6EE7B7;
696
+ --cc-badge-border: rgba(52, 211, 153, 0.30);
697
+ --cc-btn-sec-bg: #334155;
698
+ --cc-btn-sec-bg-hover: #475569;
699
+ --cc-btn-sec-text: #F8FAFC;
700
+ --cc-btn-sec-border: #475569;
701
+ --cc-close-bg: #334155;
702
+ --cc-close-bg-hover: #475569;
703
+ --cc-close-text: #CBD5E1;
704
+ --cc-ghost-hover-bg: rgba(255, 255, 255, 0.08);
705
+ }}
706
+ #challenge-clear-overlay .cc-close-btn:hover {{
707
+ background: var(--cc-close-bg-hover) !important;
708
+ color: var(--cc-text-primary) !important;
709
+ }}
710
+ #challenge-clear-overlay .cc-btn-primary:hover {{
711
+ background: var(--cc-accent-hover) !important;
712
+ transform: translateY(-1px);
713
+ }}
714
+ #challenge-clear-overlay .cc-btn-secondary:hover {{
715
+ background: var(--cc-btn-sec-bg-hover) !important;
716
+ transform: translateY(-1px);
717
+ }}
718
+ #challenge-clear-overlay .cc-btn-ghost:hover {{
719
+ background: var(--cc-ghost-hover-bg) !important;
720
+ color: var(--cc-text-primary) !important;
721
+ }}
722
+ </style>
723
+ <div id="challenge-clear-overlay" data-ts="{ts}" role="dialog" aria-modal="true" aria-labelledby="challenge-clear-title"
724
+ onclick="if (event.target === this) this.style.display = 'none';"
725
+ style="position: fixed; inset: 0; z-index: 99999; background: var(--cc-overlay-bg); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; padding: 20px; animation: ccOverlayFadeIn 0.2s ease-out;">
726
+ <div style="background: var(--cc-card-bg); color: var(--cc-text-primary); width: 100%; max-width: 440px; border-radius: 20px; padding: 32px 28px 26px; box-shadow: var(--cc-card-shadow), 0 0 0 1px var(--cc-card-border); text-align: center; position: relative; font-family: system-ui, -apple-system, sans-serif; animation: ccModalPopIn 0.32s cubic-bezier(0.16, 1, 0.3, 1);">
727
+ <button type="button" class="cc-close-btn" aria-label="Close dialog"
728
+ onclick="document.getElementById('challenge-clear-overlay').style.display='none';"
729
+ style="position: absolute; top: 14px; right: 14px; width: 32px; height: 32px; border-radius: 50%; border: none; background: var(--cc-close-bg); color: var(--cc-close-text); font-size: 16px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; transition: all 0.15s ease;">
730
+ ✕
731
+ </button>
732
+
733
+ <div style="width: 112px; height: 112px; margin: 0 auto 18px; border-radius: 50%; background: conic-gradient(var(--cc-accent) {min(score, 100.0):.1f}%, var(--cc-ring-track) 0); display: flex; align-items: center; justify-content: center; box-shadow: 0 10px 25px -5px rgba(16, 185, 129, 0.35);">
734
+ <div style="width: 92px; height: 92px; border-radius: 50%; background: var(--cc-card-bg); display: flex; flex-direction: column; align-items: center; justify-content: center;">
735
+ <span style="font-size: 24px; font-weight: 800; color: var(--cc-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.1;">{score:.1f}%</span>
736
+ <span style="font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--cc-text-muted); margin-top: 2px;">Similarity</span>
737
+ </div>
738
+ </div>
739
+
740
+ <div style="display: inline-block; padding: 4px 12px; border-radius: 9999px; background: var(--cc-badge-bg); color: var(--cc-badge-text); border: 1px solid var(--cc-badge-border); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 10px;">
741
+ 🎉 Goal Achieved (≥ 90%)
742
+ </div>
743
+
744
+ <h2 id="challenge-clear-title" style="margin: 0 0 10px; font-size: 26px; font-weight: 800; letter-spacing: -0.02em; color: var(--cc-text-primary); line-height: 1.2;">
745
+ CHALLENGE CLEAR!
746
+ </h2>
747
+
748
+ <p style="margin: 0 0 18px; font-size: 14.5px; line-height: 1.55; color: var(--cc-text-secondary);">
749
+ You successfully captured the essence of <strong style="color: var(--cc-text-primary); font-weight: 700;">{target}</strong>!
750
+ The vector embedding recognizes your artistic intent.
751
+ </p>
752
+
753
+ <div style="background: var(--cc-surface-bg); border: 1px solid var(--cc-surface-border); border-radius: 12px; padding: 10px 14px; margin-bottom: 22px; font-size: 12.5px; color: var(--cc-text-secondary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;">
754
+ Target: <strong style="color: var(--cc-text-primary);">{target}</strong> &nbsp;•&nbsp; Raw Cosine: <strong style="color: var(--cc-text-primary);">{cosine:.4f}</strong>
755
+ </div>
756
+
757
+ <div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
758
+ <button type="button" class="cc-btn-primary"
759
+ onclick="document.getElementById('challenge-clear-overlay').style.display='none'; if (window.editor && window.editor.reset_canvas) {{ window.editor.reset_canvas(); }} (document.querySelector('#next-target-btn button') || document.getElementById('next-target-btn'))?.click();"
760
+ style="flex: 1; min-width: 140px; padding: 11px 16px; border-radius: 10px; border: none; background: var(--cc-accent); color: #FFFFFF; font-size: 14px; font-weight: 700; cursor: pointer; transition: all 0.15s ease; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);">
761
+ 🎯 Next Target
762
+ </button>
763
+ <button type="button" class="cc-btn-secondary"
764
+ onclick="document.getElementById('challenge-clear-overlay').style.display='none'; if (window.editor && window.editor.reset_canvas) {{ window.editor.reset_canvas(); }} (document.querySelector('#play-again-btn button') || document.getElementById('play-again-btn'))?.click();"
765
+ style="flex: 1; min-width: 140px; padding: 11px 16px; border-radius: 10px; border: 1px solid var(--cc-btn-sec-border); background: var(--cc-btn-sec-bg); color: var(--cc-btn-sec-text); font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;">
766
+ 🔄 Play Again
767
+ </button>
768
+ </div>
769
+ <button type="button" class="cc-btn-ghost"
770
+ onclick="document.getElementById('challenge-clear-overlay').style.display='none';"
771
+ style="margin-top: 10px; width: 100%; padding: 8px 12px; border-radius: 8px; border: none; background: transparent; color: var(--cc-text-muted); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s ease;">
772
+ Continue Viewing Drawing
773
+ </button>
774
+ </div>
775
+ </div>
776
+ """
777
+
778
+
779
+ _CANVAS_RESET_COUNTER = 0
780
+
781
+
782
+ def make_blank_canvas() -> Dict[str, Any]:
783
+ """Create a fresh blank EditorValue dict with a unique layer URL so Gradio Sketchpad clears all strokes."""
784
+ global _CANVAS_RESET_COUNTER
785
+ _CANVAS_RESET_COUNTER += 1
786
+ layer_img = Image.new("RGBA", (420, 420), (0, 0, 0, 0))
787
+ x = _CANVAS_RESET_COUNTER % 420
788
+ y = (_CANVAS_RESET_COUNTER // 420) % 420
789
+ layer_img.putpixel((x, y), (255, 255, 255, 1))
790
+ return {
791
+ "background": None,
792
+ "layers": [layer_img],
793
+ "composite": None,
794
+ }
795
+
796
+
797
+ def evaluate_sketch(sketch_data: Any, theme_name: str, target_object: str) -> Tuple[Dict[str, float], str, str]:
798
+ img = extract_pil_image(sketch_data)
799
+ result = ENGINE.evaluate_drawing(img, theme_name, target_object)
800
+ return format_scoreboard(result), format_status_message(result), format_popup_dialog(result)
801
+
802
+
803
+ def on_theme_change(theme_name: str):
804
+ objects = get_theme_objects(theme_name)
805
+ default_target = objects[0]
806
+ result = ENGINE.evaluate_drawing(None, theme_name, default_target)
807
+ return (
808
+ gr.update(choices=objects, value=default_target),
809
+ make_blank_canvas(),
810
+ format_scoreboard(result),
811
+ format_status_message(result),
812
+ format_popup_dialog(result),
813
+ )
814
+
815
+
816
+ def on_target_change(theme_name: str, target_object: str):
817
+ result = ENGINE.evaluate_drawing(None, theme_name, target_object)
818
+ return (
819
+ make_blank_canvas(),
820
+ format_scoreboard(result),
821
+ format_status_message(result),
822
+ format_popup_dialog(result),
823
+ )
824
+
825
+
826
+ def on_next_target(theme_name: str, current_target: str):
827
+ objects = get_theme_objects(theme_name)
828
+ idx = objects.index(current_target) if current_target in objects else 0
829
+ next_target = objects[(idx + 1) % len(objects)]
830
+ result = ENGINE.evaluate_drawing(None, theme_name, next_target)
831
+ return (
832
+ gr.update(value=next_target),
833
+ make_blank_canvas(),
834
+ format_scoreboard(result),
835
+ format_status_message(result),
836
+ format_popup_dialog(result),
837
+ )
838
+
839
+
840
+ def create_app() -> gr.Blocks:
841
+ """Construct minimal Gradio Blocks application using built-in Soft theme."""
842
+ # Build and cache reference embedding vectors while initializing the Gradio app
843
+ ENGINE.ensure_reference_embeddings()
844
+
845
+ theme = gr.themes.Soft()
846
+ blocks_kwargs = {"title": "Embedding Draw Challenge — EmbeddingGemma 2"}
847
+ if "theme" in inspect.signature(gr.Blocks.__init__).parameters:
848
+ blocks_kwargs["theme"] = theme
849
+
850
+ with gr.Blocks(**blocks_kwargs) as app:
851
+ app.theme = theme
852
+
853
+ gr.Markdown(
854
+ "# 🎨 Embedding Draw Challenge\n"
855
+ "Draw the target object below. **[EmbeddingGemma 2](https://ai.google.dev/gemma/docs/embeddinggemma)** guesses what you're drawing. Reach **90%** similarity on the target to clear the challenge!\n\n"
856
+ "### 🕹️ How to Play\n"
857
+ "1. **Choose One Target**: Select a **Theme** and pick **one single object** from the **Target Object** dropdown to draw.\n"
858
+ "2. **Sketch Your Object**: Use the drawing canvas and color palette to sketch your chosen target.\n"
859
+ "3. **Outscore Competing Distractors**: The other items listed in the dropdown act as competing distractors on the **Similarity Scoreboard**.\n"
860
+ "4. **Reach 90% Similarity**: Refine your drawing until your chosen **Target Object** hits **90% or higher** to clear the challenge, then select another object from the dropdown to try a new round!\n"
861
+ )
862
+
863
+ with gr.Row():
864
+ theme_dropdown = gr.Dropdown(
865
+ choices=list(THEME_DATABASE.keys()),
866
+ value="Fruit",
867
+ label="Theme",
868
+ info="Select a drawing category",
869
+ scale=2,
870
+ )
871
+ target_dropdown = gr.Dropdown(
872
+ choices=get_theme_objects("Fruit"),
873
+ value="Banana",
874
+ label="Target Object",
875
+ info="Draw ONLY this single selected object (other choices act as competing distractors)",
876
+ scale=2,
877
+ )
878
+
879
+ initial_result = ENGINE.evaluate_drawing(None, "Fruit", "Banana")
880
+
881
+ with gr.Row():
882
+ with gr.Column(scale=1):
883
+ sketchpad = gr.Sketchpad(
884
+ label="Drawing Canvas",
885
+ type="pil",
886
+ image_mode="RGBA",
887
+ canvas_size=(420, 420),
888
+ layers=False,
889
+ brush=gr.Brush(
890
+ colors=[
891
+ "#1A1A1B",
892
+ "#FFE135",
893
+ "#EF4444",
894
+ "#2563EB",
895
+ "#10B981",
896
+ "#9333EA",
897
+ "#94A3B8",
898
+ "#F97316",
899
+ "#78350F",
900
+ "#FFFFFF",
901
+ ],
902
+ default_size=8,
903
+ ),
904
+ )
905
+
906
+ with gr.Column(scale=1):
907
+ scoreboard = gr.Label(
908
+ value=format_scoreboard(initial_result),
909
+ num_top_classes=8,
910
+ label="Similarity Scoreboard (Goal: ≥ 90%)",
911
+ )
912
+ status_box = gr.Markdown(
913
+ value=format_status_message(initial_result),
914
+ )
915
+ with gr.Row():
916
+ play_again_btn = gr.Button(
917
+ "🔄 Play Again",
918
+ variant="secondary",
919
+ elem_id="play-again-btn",
920
+ )
921
+ next_target_btn = gr.Button(
922
+ "🎯 Next Target",
923
+ variant="primary",
924
+ elem_id="next-target-btn",
925
+ )
926
+
927
+ popup_dialog = gr.HTML(
928
+ value=format_popup_dialog(initial_result),
929
+ elem_id="challenge-clear-popup",
930
+ apply_default_css=False,
931
+ )
932
+
933
+ clear_canvas_js_1 = "(a) => { if (window.editor && window.editor.reset_canvas) { window.editor.reset_canvas(); } return a; }"
934
+ clear_canvas_js_2 = "(a, b) => { if (window.editor && window.editor.reset_canvas) { window.editor.reset_canvas(); } return [a, b]; }"
935
+
936
+ # Wire events
937
+ sketchpad.change(
938
+ fn=evaluate_sketch,
939
+ inputs=[sketchpad, theme_dropdown, target_dropdown],
940
+ outputs=[scoreboard, status_box, popup_dialog],
941
+ )
942
+
943
+ theme_dropdown.change(
944
+ fn=on_theme_change,
945
+ inputs=[theme_dropdown],
946
+ outputs=[target_dropdown, sketchpad, scoreboard, status_box, popup_dialog],
947
+ js=clear_canvas_js_1,
948
+ )
949
+
950
+ target_dropdown.change(
951
+ fn=on_target_change,
952
+ inputs=[theme_dropdown, target_dropdown],
953
+ outputs=[sketchpad, scoreboard, status_box, popup_dialog],
954
+ js=clear_canvas_js_2,
955
+ )
956
+
957
+ play_again_btn.click(
958
+ fn=on_target_change,
959
+ inputs=[theme_dropdown, target_dropdown],
960
+ outputs=[sketchpad, scoreboard, status_box, popup_dialog],
961
+ js=clear_canvas_js_2,
962
+ )
963
+
964
+ next_target_btn.click(
965
+ fn=on_next_target,
966
+ inputs=[theme_dropdown, target_dropdown],
967
+ outputs=[target_dropdown, sketchpad, scoreboard, status_box, popup_dialog],
968
+ js=clear_canvas_js_2,
969
+ )
970
+
971
+ return app
972
+
973
+
974
+ if __name__ == "__main__":
975
+ app = create_app()
976
+ launch_kwargs = {
977
+ "server_name": "0.0.0.0",
978
+ "server_port": 7860,
979
+ }
980
+ if "theme" in inspect.signature(app.launch).parameters:
981
+ launch_kwargs["theme"] = gr.themes.Soft()
982
+ app.launch(**launch_kwargs)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ huggingface-hub==1.33.0
2
+ sentence-transformers>=6.1.0
3
+ sentencepiece
4
+ transformers
5
+ torch
6
+ torchvision
7
+ torchaudio
8
+ pillow
9
+ soundfile
10
+ torchcodec