SandiyoS commited on
Commit
4f603e1
Β·
verified Β·
1 Parent(s): dfdf7bf

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +140 -181
app.py CHANGED
@@ -1,15 +1,10 @@
1
  #!/usr/bin/env python3
2
  """
3
- πŸ”₯ Icon Challenge Solver - FINAL FIX HF Spaces
4
  """
5
 
6
  # ===================== ASYNCIO WARNING FIX =====================
7
  import asyncio
8
- import sys
9
-
10
- # Patch: Suppress "Exception ignored in BaseEventLoop.__del__" on Python 3.12
11
- # Ini terjadi karena Gradio/Ultralytics membuat event loop tanpa close eksplisit
12
- _original_event_loop_del = asyncio.BaseEventLoop.__del__
13
 
14
  def _safe_event_loop_del(self):
15
  try:
@@ -22,7 +17,6 @@ def _safe_event_loop_del(self):
22
 
23
  asyncio.BaseEventLoop.__del__ = _safe_event_loop_del
24
 
25
- # Suppress warnings
26
  import warnings
27
  warnings.filterwarnings("ignore", category=ResourceWarning)
28
  warnings.filterwarnings("ignore", message="Exception ignored in.*BaseEventLoop.*")
@@ -30,24 +24,20 @@ warnings.filterwarnings("ignore")
30
 
31
  # ===================== IMPORTS =====================
32
  import os
33
- import io
34
- import re
35
  import gc
36
- import base64
37
  import json
38
  import logging
39
  from pathlib import Path
40
- from typing import List, Union
41
 
42
  import torch
43
  import torch.nn as nn
44
  import torch.nn.functional as F
45
- from torch.cuda.amp import autocast
46
 
47
  import numpy as np
48
  from PIL import Image
49
  import cv2
50
- import torchvision.transforms as T
51
 
52
  import gradio as gr
53
  import spaces
@@ -56,7 +46,6 @@ from ultralytics import YOLO
56
  import open_clip
57
  from peft import PeftModel
58
 
59
- # ===================== ENV SETUP =====================
60
  os.environ["PYTHONASYNCIODEBUG"] = "0"
61
  os.environ["GRADIO_ANALYTICS_ENABLED"] = "0"
62
  os.environ["YOLO_CONFIG_DIR"] = "/tmp/Ultralytics"
@@ -80,7 +69,14 @@ class Config:
80
 
81
  CONFIG = Config()
82
 
83
- # ===================== MODEL CLASSES =====================
 
 
 
 
 
 
 
84
  class ProjectionHead(nn.Module):
85
  def __init__(self, in_dim=1024, hidden_dim=512, out_dim=256, dropout=0.1):
86
  super().__init__()
@@ -93,66 +89,41 @@ class ProjectionHead(nn.Module):
93
  self.skip = nn.Linear(in_dim, out_dim)
94
 
95
  def forward(self, x):
 
96
  if x.shape[0] <= 1:
97
- self.train(False)
98
  residual = self.skip(x)
99
- out = self.layer1(x)
100
- out = self.bn1(out)
101
- out = self.act(out)
102
- out = self.dropout(out)
103
- out = self.layer2(out)
104
- out = self.bn2(out)
105
- return F.normalize(out + residual, p=2, dim=1)
106
-
107
- class IconClassifier(nn.Module):
108
- def __init__(self, backbone, head_state, num_classes=21):
 
 
 
 
 
109
  super().__init__()
110
- self.backbone = backbone
111
- self.projection = ProjectionHead()
112
-
113
- # Load projection weights
114
- if 'projection' in head_state:
115
- proj_dict = head_state['projection']
116
- cleaned = {}
117
- for k, v in proj_dict.items():
118
- if k.startswith('projection.'):
119
- cleaned[k[11:]] = v
120
- else:
121
- cleaned[k] = v
122
- self.projection.load_state_dict(cleaned, strict=False)
123
-
124
- # Find weight dengan berbagai kemungkinan key
125
- weight = None
126
- if 'arc_margin' in head_state and 'weight' in head_state['arc_margin']:
127
- weight = head_state['arc_margin']['weight']
128
- elif 'weight' in head_state:
129
- weight = head_state['weight']
130
- elif 'fc.weight' in head_state:
131
- weight = head_state['fc.weight']
132
-
133
- if weight is None:
134
- logger.warning("Head weight not found, initializing random!")
135
- weight = torch.randn(num_classes, 256)
136
-
137
- self.weight = nn.Parameter(weight)
138
- self.s = 30.0
139
 
140
- def forward(self, x):
141
- features = self.backbone(x)
142
- embedding = self.projection(features)
143
- logits = F.linear(F.normalize(embedding), F.normalize(self.weight))
144
- return logits * self.s
145
 
146
- def get_embedding(self, x):
147
- features = self.backbone(x)
148
- embedding = self.projection(features)
149
- return F.normalize(embedding, p=2, dim=1)
150
-
151
- custom_preprocess = T.Compose([
152
- T.Resize((256, 256), interpolation=T.InterpolationMode.NEAREST),
153
- T.ToTensor(),
154
- T.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
155
- ])
156
 
157
  # ===================== MODEL MANAGER =====================
158
  class ModelManager:
@@ -166,105 +137,76 @@ class ModelManager:
166
  def init(self):
167
  if self.initialized:
168
  return
169
-
170
- logger.info(f"Working directory: {os.getcwd()}")
171
- logger.info(f"Base directory: {BASE_DIR}")
172
- logger.info("Loading models on GPU...")
173
 
174
- # YOLO
 
 
 
175
  yolo_path = Path(CONFIG.yolo_model_path)
176
- logger.info(f"Looking for YOLO at: {yolo_path} (exists: {yolo_path.exists()})")
177
-
178
  if not yolo_path.exists():
179
- alt_path = BASE_DIR / "best.pt"
180
- if alt_path.exists():
181
- yolo_path = alt_path
182
- else:
183
- raise FileNotFoundError(f"YOLO not found: {yolo_path}")
184
 
185
  self.yolo = YOLO(str(yolo_path)).to(CONFIG.device)
186
  logger.info(f"βœ… YOLO loaded: {yolo_path}")
187
 
188
- # Config
189
  lora_base = Path(CONFIG.lora_model_dir)
190
- logger.info(f"Looking for LoRA at: {lora_base} (exists: {lora_base.exists()})")
191
-
192
  if not lora_base.exists():
193
- alt_lora = BASE_DIR / "icon_model_hybrid"
194
- if alt_lora.exists():
195
- lora_base = alt_lora
196
- else:
197
- raise FileNotFoundError(f"LoRA dir not found: {lora_base}")
198
-
199
- config_path = lora_base / "config.json"
200
- if not config_path.exists():
201
- raise FileNotFoundError(f"config.json not found in {lora_base}")
202
-
203
- with open(config_path) as f:
204
- model_config = json.load(f)
205
-
206
- num_classes = model_config.get('num_classes') or len(model_config.get('class_to_idx', {}))
207
- logger.info(f"Num classes: {num_classes}")
208
-
209
- # Backbone
210
- logger.info(f"Loading backbone: {CONFIG.model_name}")
211
- backbone, _, _ = open_clip.create_model_and_transforms(CONFIG.model_name, pretrained='webli')
212
- backbone = backbone.visual.to(CONFIG.device)
213
- logger.info("βœ… Backbone loaded")
214
 
215
- # LoRA Adapter
216
  adapter_dir = lora_base / CONFIG.adapter_subdir
217
- peft_path = str(adapter_dir) if adapter_dir.exists() else str(lora_base)
218
- logger.info(f"Loading LoRA from: {peft_path}")
219
-
220
- try:
221
- model_with_lora = PeftModel.from_pretrained(
222
- backbone, peft_path, is_trainable=False
223
- )
224
- logger.info("βœ… LoRA adapter loaded")
225
- except Exception as e:
226
- logger.error(f"LoRA load failed: {e}")
227
- model_with_lora = backbone
228
- logger.warning("⚠️ Using backbone without LoRA!")
229
 
230
- # Head Weights
231
  head_candidates = [
232
  lora_base / "head_weights.pt",
233
  lora_base / "weights.pt",
234
- lora_base / "classifier.pt",
235
- lora_base / "model.pt"
236
  ]
237
- head_state = None
238
  head_path = None
239
- for hpath in head_candidates:
240
- if hpath.exists():
241
- head_state = torch.load(hpath, map_location=CONFIG.device)
242
- head_path = hpath
243
- logger.info(f"βœ… Head weights loaded: {hpath}")
244
  break
245
-
246
- if head_state is None:
247
- raise FileNotFoundError(f"Head weights not found! Tried: {head_candidates}")
248
 
249
- self.inference_model = IconClassifier(model_with_lora, head_state, num_classes).to(CONFIG.device).eval()
250
- logger.info("βœ… IconClassifier created")
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- self.idx_to_class = {int(k): v for k, v in model_config.get('idx_to_class', {}).items()}
253
- self.class_to_idx = {v: int(k) for k, v in self.idx_to_class.items()}
254
  self.initialized = True
255
- logger.info("βœ… All models loaded successfully!")
256
 
 
257
  def yolo_detect_and_crop(self, image: Image.Image) -> Image.Image:
258
  if image.mode != 'RGB':
259
  image = image.convert('RGB')
260
-
261
  img_np = np.array(image)
262
  img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
263
-
264
  results = self.yolo(img_bgr, conf=CONFIG.yolo_conf, imgsz=CONFIG.yolo_img_size, verbose=False)
265
-
266
  best_box = None
267
- best_conf = 0
268
  for result in results:
269
  if result.boxes is None:
270
  continue
@@ -279,69 +221,82 @@ class ModelManager:
279
  h, w = img_np.shape[:2]
280
  x1, y1, x2, y2 = max(0, x1), max(0, y1), min(w, x2), min(h, y2)
281
  if (x2 - x1) >= CONFIG.yolo_min_crop_size and (y2 - y1) >= CONFIG.yolo_min_crop_size:
282
- cropped = img_np[y1:y2, x1:x2]
283
- return Image.fromarray(cropped)
284
-
285
- return image
286
 
287
- def extract_embedding(self, image: Image.Image) -> torch.Tensor:
288
- if image.mode != 'RGB':
289
- image = image.convert('RGB')
290
-
291
- img_tensor = custom_preprocess(image).unsqueeze(0).to(CONFIG.device)
292
-
293
- with torch.no_grad(), autocast():
294
- emb = self.inference_model.get_embedding(img_tensor)
295
- return emb
296
 
 
297
  def match_options(self, challenge_crop: Image.Image, options: List[Image.Image]):
298
- challenge_emb = self.extract_embedding(challenge_crop)
299
-
300
- option_embs = []
301
- for opt in options:
302
- option_embs.append(self.extract_embedding(opt))
303
-
304
- option_stack = torch.cat(option_embs, dim=0)
305
- similarities = (challenge_emb @ option_stack.T).squeeze(0).cpu().numpy()
306
-
307
- best_idx = int(np.argmax(similarities))
308
-
309
- sim_scaled = similarities * 10
310
- sim_max = np.max(sim_scaled)
311
- exp_sim = np.exp(sim_scaled - sim_max)
312
- probs = exp_sim / exp_sim.sum()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  return {
315
  "predicted_index": best_idx,
316
  "confidence": float(probs[best_idx]),
317
- "all_scores": {f"option_{i}": {
318
- "similarity": float(similarities[i]),
319
- "probability": float(probs[i])
320
- } for i in range(len(options))},
 
 
 
 
321
  "predicted_class": self.idx_to_class.get(best_idx, "unknown")
322
  }
323
 
324
  manager = ModelManager()
325
 
326
- # ===================== MAIN FUNCTION =====================
327
  @spaces.GPU(request_timeout=180, enable=True)
328
  def solve_gradio(challenge_img, *options):
329
  if not manager.initialized:
330
  try:
331
  manager.init()
332
  except Exception as e:
333
- logger.error(f"Model init failed: {e}", exc_info=True)
334
- return f"❌ Model initialization failed: {str(e)}", None
335
 
336
  try:
337
  if challenge_img is None or any(o is None for o in options):
338
  return "❌ Semua gambar harus diisi!", None
339
 
 
340
  if isinstance(challenge_img, np.ndarray):
341
  challenge_img = Image.fromarray(challenge_img)
342
  elif isinstance(challenge_img, str):
343
  challenge_img = Image.open(challenge_img)
344
-
345
  options_pil = []
346
  for opt in options:
347
  if isinstance(opt, np.ndarray):
@@ -352,11 +307,14 @@ def solve_gradio(challenge_img, *options):
352
  options_pil.append(opt)
353
  else:
354
  options_pil.append(None)
355
-
356
  if any(o is None for o in options_pil):
357
- return "❌ Format gambar option tidak valid!", None
358
 
 
359
  challenge_crop = manager.yolo_detect_and_crop(challenge_img)
 
 
360
  result = manager.match_options(challenge_crop, options_pil)
361
 
362
  best_idx = result["predicted_index"]
@@ -364,6 +322,7 @@ def solve_gradio(challenge_img, *options):
364
  "🎯 HASIL\n",
365
  f"βœ… Best Option: {best_idx + 1}",
366
  f"Confidence: {result['confidence']:.4f} ({result['confidence']*100:.2f}%)",
 
367
  f"Class: {result['predicted_class']}\n",
368
  "πŸ“Š Skor Semua Opsi:"
369
  ]
@@ -371,7 +330,7 @@ def solve_gradio(challenge_img, *options):
371
  for i in range(len(options_pil)):
372
  s = result["all_scores"][f"option_{i}"]
373
  marker = "βœ…" if i == best_idx else " "
374
- lines.append(f"{marker} Option {i+1}: {s['probability']:.4f} ({s['similarity']:.4f})")
375
 
376
  gc.collect()
377
  if torch.cuda.is_available():
@@ -384,9 +343,9 @@ def solve_gradio(challenge_img, *options):
384
  return f"❌ Error: {str(e)}", None
385
 
386
 
387
- # ===================== GRADIO INTERFACE =====================
388
  with gr.Blocks(title="πŸ”₯ Icon Challenge Solver", theme=gr.themes.Soft()) as demo:
389
- gr.Markdown("# πŸ”₯ Icon Challenge Solver\n**YOLO + SigLIP LoRA Hybrid**")
390
 
391
  with gr.Row():
392
  with gr.Column(scale=1):
 
1
  #!/usr/bin/env python3
2
  """
3
+ πŸ”₯ Icon Challenge Solver - 100% Exact LoRA Inference Match
4
  """
5
 
6
  # ===================== ASYNCIO WARNING FIX =====================
7
  import asyncio
 
 
 
 
 
8
 
9
  def _safe_event_loop_del(self):
10
  try:
 
17
 
18
  asyncio.BaseEventLoop.__del__ = _safe_event_loop_del
19
 
 
20
  import warnings
21
  warnings.filterwarnings("ignore", category=ResourceWarning)
22
  warnings.filterwarnings("ignore", message="Exception ignored in.*BaseEventLoop.*")
 
24
 
25
  # ===================== IMPORTS =====================
26
  import os
 
 
27
  import gc
 
28
  import json
29
  import logging
30
  from pathlib import Path
31
+ from typing import List
32
 
33
  import torch
34
  import torch.nn as nn
35
  import torch.nn.functional as F
36
+ import torchvision.transforms as T
37
 
38
  import numpy as np
39
  from PIL import Image
40
  import cv2
 
41
 
42
  import gradio as gr
43
  import spaces
 
46
  import open_clip
47
  from peft import PeftModel
48
 
 
49
  os.environ["PYTHONASYNCIODEBUG"] = "0"
50
  os.environ["GRADIO_ANALYTICS_ENABLED"] = "0"
51
  os.environ["YOLO_CONFIG_DIR"] = "/tmp/Ultralytics"
 
69
 
70
  CONFIG = Config()
71
 
72
+ # ===================== PREPROCESS (EXACT MATCH) =====================
73
+ preprocess = T.Compose([
74
+ T.Resize((256, 256), interpolation=T.InterpolationMode.NEAREST),
75
+ T.ToTensor(),
76
+ T.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
77
+ ])
78
+
79
+ # ===================== PROJECTION HEAD (EXACT MATCH) =====================
80
  class ProjectionHead(nn.Module):
81
  def __init__(self, in_dim=1024, hidden_dim=512, out_dim=256, dropout=0.1):
82
  super().__init__()
 
89
  self.skip = nn.Linear(in_dim, out_dim)
90
 
91
  def forward(self, x):
92
+ # EXACT: self.eval() bukan self.train(False)
93
  if x.shape[0] <= 1:
94
+ self.eval()
95
  residual = self.skip(x)
96
+ x = self.layer1(x)
97
+ x = self.bn1(x)
98
+ x = self.act(x)
99
+ x = self.dropout(x)
100
+ x = self.layer2(x)
101
+ x = self.bn2(x)
102
+ return F.normalize(x + residual, p=2, dim=1)
103
+
104
+ # ===================== ICON MATCHER (EXACT MATCH REFERENSI) =====================
105
+ class IconMatcher(nn.Module):
106
+ """
107
+ 100% match dengan IconMatcherCPU dari referensi,
108
+ cuma ditambah .to(device) dan path resolution.
109
+ """
110
+ def __init__(self, lora_path, head_path, device='cuda'):
111
  super().__init__()
112
+ backbone, _, _ = open_clip.create_model_and_transforms(
113
+ 'ViT-L-16-SigLIP-256', pretrained='webli'
114
+ )
115
+ self.backbone = PeftModel.from_pretrained(backbone.visual, lora_path)
116
+ self.backbone = self.backbone.to(device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ head_state = torch.load(head_path, map_location=device)
119
+ self.projection = ProjectionHead(1024, 512, 256)
120
+ self.projection.load_state_dict(head_state['projection'])
121
+ self.projection = self.projection.to(device)
 
122
 
123
+ def forward(self, x):
124
+ with torch.no_grad():
125
+ features = self.backbone(x)
126
+ return self.projection(features)
 
 
 
 
 
 
127
 
128
  # ===================== MODEL MANAGER =====================
129
  class ModelManager:
 
137
  def init(self):
138
  if self.initialized:
139
  return
 
 
 
 
140
 
141
+ logger.info(f"Base dir: {BASE_DIR}")
142
+ logger.info("Loading models...")
143
+
144
+ # --- YOLO ---
145
  yolo_path = Path(CONFIG.yolo_model_path)
 
 
146
  if not yolo_path.exists():
147
+ yolo_path = BASE_DIR / "best.pt"
148
+ if not yolo_path.exists():
149
+ raise FileNotFoundError(f"YOLO not found: {yolo_path}")
 
 
150
 
151
  self.yolo = YOLO(str(yolo_path)).to(CONFIG.device)
152
  logger.info(f"βœ… YOLO loaded: {yolo_path}")
153
 
154
+ # --- LoRA Path ---
155
  lora_base = Path(CONFIG.lora_model_dir)
 
 
156
  if not lora_base.exists():
157
+ lora_base = BASE_DIR / "icon_model_hybrid"
158
+ if not lora_base.exists():
159
+ raise FileNotFoundError(f"LoRA dir not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
+ # Cek adapter subdir atau langsung root
162
  adapter_dir = lora_base / CONFIG.adapter_subdir
163
+ lora_path = str(adapter_dir) if adapter_dir.exists() else str(lora_base)
164
+ logger.info(f"LoRA path: {lora_path}")
 
 
 
 
 
 
 
 
 
 
165
 
166
+ # --- Head Weights ---
167
  head_candidates = [
168
  lora_base / "head_weights.pt",
169
  lora_base / "weights.pt",
 
 
170
  ]
 
171
  head_path = None
172
+ for h in head_candidates:
173
+ if h.exists():
174
+ head_path = str(h)
175
+ logger.info(f"βœ… Head weights: {h}")
 
176
  break
177
+ if head_path is None:
178
+ raise FileNotFoundError(f"Head weights not found in {lora_base}")
 
179
 
180
+ # --- Load IconMatcher (EXACT MATCH REFERENSI) ---
181
+ self.matcher = IconMatcher(lora_path, head_path, device=CONFIG.device).eval()
182
+ logger.info("βœ… IconMatcher loaded (100% exact with reference)")
183
+
184
+ # --- Config untuk class names ---
185
+ config_path = lora_base / "config.json"
186
+ if config_path.exists():
187
+ with open(config_path) as f:
188
+ cfg = json.load(f)
189
+ self.idx_to_class = {
190
+ int(k): v for k, v in cfg.get('idx_to_class', {}).items()
191
+ }
192
+ else:
193
+ self.idx_to_class = {}
194
 
 
 
195
  self.initialized = True
196
+ logger.info("βœ… All models ready!")
197
 
198
+ # --- YOLO: Detect + Crop ---
199
  def yolo_detect_and_crop(self, image: Image.Image) -> Image.Image:
200
  if image.mode != 'RGB':
201
  image = image.convert('RGB')
202
+
203
  img_np = np.array(image)
204
  img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
205
+
206
  results = self.yolo(img_bgr, conf=CONFIG.yolo_conf, imgsz=CONFIG.yolo_img_size, verbose=False)
207
+
208
  best_box = None
209
+ best_conf = 0.0
210
  for result in results:
211
  if result.boxes is None:
212
  continue
 
221
  h, w = img_np.shape[:2]
222
  x1, y1, x2, y2 = max(0, x1), max(0, y1), min(w, x2), min(h, y2)
223
  if (x2 - x1) >= CONFIG.yolo_min_crop_size and (y2 - y1) >= CONFIG.yolo_min_crop_size:
224
+ return Image.fromarray(img_np[y1:y2, x1:x2])
 
 
 
225
 
226
+ return image
 
 
 
 
 
 
 
 
227
 
228
+ # --- INFERENCE: 100% EXACT MATCH REFERENSI ---
229
  def match_options(self, challenge_crop: Image.Image, options: List[Image.Image]):
230
+ """
231
+ EXACT MATCH dengan:
232
+ imgs = [Image.open(p).convert('RGB') for p in [target_path] + option_paths]
233
+ batch = torch.stack([preprocess(img) for img in imgs])
234
+ embs = model(batch)
235
+ scores = torch.mm(embs[0:1], embs[1:].t()).squeeze(0)
236
+ """
237
+ # 1. Siapkan semua image (challenge + 8 options)
238
+ imgs = [challenge_crop] + options
239
+ pil_imgs = []
240
+ for img in imgs:
241
+ if img.mode != 'RGB':
242
+ img = img.convert('RGB')
243
+ pil_imgs.append(img)
244
+
245
+ # 2. Preprocess & stack (EXACT: torch.stack + preprocess)
246
+ tensors = [preprocess(img) for img in pil_imgs]
247
+ batch = torch.stack(tensors).to(CONFIG.device)
248
+
249
+ # 3. Forward sekali untuk semua (EXACT: model(batch))
250
+ with torch.no_grad():
251
+ embs = self.matcher(batch)
252
+
253
+ # 4. Similarity (EXACT: torch.mm(embs[0:1], embs[1:].t()).squeeze(0))
254
+ scores = torch.mm(embs[0:1], embs[1:].t()).squeeze(0)
255
+ best_idx = int(torch.argmax(scores).item())
256
+ max_score = float(scores.max().item())
257
+
258
+ # 5. Buat probability dari scores untuk display (optional, nggak di referensi tapi buat UI)
259
+ # Karena embedding sudah L2 normalized, scores = cosine similarity [-1, 1]
260
+ # Kita convert ke "probability-like" dengan softmax(scores * 10) seperti sebelumnya
261
+ # tapi INFERENCE UTAMA tetap pakai torch.mm exact di atas
262
+ probs = torch.softmax(scores * 10, dim=0).cpu().numpy()
263
 
264
  return {
265
  "predicted_index": best_idx,
266
  "confidence": float(probs[best_idx]),
267
+ "max_score": max_score,
268
+ "all_scores": {
269
+ f"option_{i}": {
270
+ "score": float(scores[i]),
271
+ "probability": float(probs[i])
272
+ }
273
+ for i in range(len(options))
274
+ },
275
  "predicted_class": self.idx_to_class.get(best_idx, "unknown")
276
  }
277
 
278
  manager = ModelManager()
279
 
280
+ # ===================== GRADIO FUNCTION =====================
281
  @spaces.GPU(request_timeout=180, enable=True)
282
  def solve_gradio(challenge_img, *options):
283
  if not manager.initialized:
284
  try:
285
  manager.init()
286
  except Exception as e:
287
+ logger.error(f"Init failed: {e}", exc_info=True)
288
+ return f"❌ Init failed: {e}", None
289
 
290
  try:
291
  if challenge_img is None or any(o is None for o in options):
292
  return "❌ Semua gambar harus diisi!", None
293
 
294
+ # Convert ke PIL
295
  if isinstance(challenge_img, np.ndarray):
296
  challenge_img = Image.fromarray(challenge_img)
297
  elif isinstance(challenge_img, str):
298
  challenge_img = Image.open(challenge_img)
299
+
300
  options_pil = []
301
  for opt in options:
302
  if isinstance(opt, np.ndarray):
 
307
  options_pil.append(opt)
308
  else:
309
  options_pil.append(None)
310
+
311
  if any(o is None for o in options_pil):
312
+ return "❌ Format option tidak valid!", None
313
 
314
+ # YOLO crop
315
  challenge_crop = manager.yolo_detect_and_crop(challenge_img)
316
+
317
+ # Inference (EXACT MATCH REFERENSI)
318
  result = manager.match_options(challenge_crop, options_pil)
319
 
320
  best_idx = result["predicted_index"]
 
322
  "🎯 HASIL\n",
323
  f"βœ… Best Option: {best_idx + 1}",
324
  f"Confidence: {result['confidence']:.4f} ({result['confidence']*100:.2f}%)",
325
+ f"Raw Score: {result['max_score']:.4f}",
326
  f"Class: {result['predicted_class']}\n",
327
  "πŸ“Š Skor Semua Opsi:"
328
  ]
 
330
  for i in range(len(options_pil)):
331
  s = result["all_scores"][f"option_{i}"]
332
  marker = "βœ…" if i == best_idx else " "
333
+ lines.append(f"{marker} Option {i+1}: prob={s['probability']:.4f} score={s['score']:.4f}")
334
 
335
  gc.collect()
336
  if torch.cuda.is_available():
 
343
  return f"❌ Error: {str(e)}", None
344
 
345
 
346
+ # ===================== GRADIO UI =====================
347
  with gr.Blocks(title="πŸ”₯ Icon Challenge Solver", theme=gr.themes.Soft()) as demo:
348
+ gr.Markdown("# πŸ”₯ Icon Challenge Solver\n**YOLO + SigLIP LoRA Hybrid (Exact Match Inference)**")
349
 
350
  with gr.Row():
351
  with gr.Column(scale=1):