SandiyoS commited on
Commit
0e9f191
Β·
verified Β·
1 Parent(s): 6acda67

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +597 -0
app.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ πŸ”₯ Triple Hybrid API + Gradio UI
4
+ FastAPI backend + Gradio frontend (1 app, 2 interface)
5
+ """
6
+
7
+ import os
8
+ import io
9
+ import re
10
+ import gc
11
+ import base64
12
+ import json
13
+ import math
14
+ import logging
15
+ import warnings
16
+ from pathlib import Path
17
+ from typing import List, Union, Optional, Tuple
18
+ from dataclasses import dataclass
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+ from torch.cuda.amp import autocast
24
+
25
+ import numpy as np
26
+ from PIL import Image
27
+ import cv2
28
+ import torchvision.transforms as T
29
+
30
+ from fastapi import FastAPI, Request, HTTPException, File, UploadFile
31
+ from fastapi.concurrency import run_in_threadpool
32
+ from fastapi.middleware.cors import CORSMiddleware
33
+ from fastapi.responses import HTMLResponse
34
+ from pydantic import BaseModel
35
+ from contextlib import asynccontextmanager
36
+
37
+ # Gradio
38
+ import gradio as gr
39
+ import spaces # ← DITAMBAHKAN untuk HF Spaces GPU
40
+
41
+ from ultralytics import YOLO
42
+ import open_clip
43
+ from peft import PeftModel
44
+
45
+ warnings.filterwarnings("ignore")
46
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # ═══════════════════════════════════════════════════════════════
50
+ # CONFIG
51
+ # ═══════════════════════════════════════════════════════════════
52
+ @dataclass
53
+ class Config:
54
+ device: str = "cuda" if torch.cuda.is_available() else "cpu"
55
+ yolo_model_path: str = "./yolo/best.pt"
56
+ yolo_conf: float = 0.25
57
+ yolo_img_size: int = 640
58
+ yolo_min_crop_size: int = 32
59
+ yolo_min_conf: float = 0.3
60
+ lora_model_dir: str = "./icon_model_hybrid"
61
+ model_name: str = "ViT-L-16-SigLIP-256"
62
+ img_size: int = 256
63
+
64
+ CONFIG = Config()
65
+
66
+
67
+ # ═══════════════════════════════════════════════════════════════
68
+ # PROJECTION HEAD (Sesuai Training)
69
+ # ═══════════════════════════════════════════════════════════════
70
+ class ProjectionHead(nn.Module):
71
+ def __init__(self, in_dim=1024, hidden_dim=512, out_dim=256, dropout=0.1):
72
+ super().__init__()
73
+ self.layer1 = nn.Linear(in_dim, hidden_dim)
74
+ self.bn1 = nn.BatchNorm1d(hidden_dim)
75
+ self.act = nn.GELU()
76
+ self.dropout = nn.Dropout(dropout)
77
+ self.layer2 = nn.Linear(hidden_dim, out_dim)
78
+ self.bn2 = nn.BatchNorm1d(out_dim)
79
+ self.skip = nn.Linear(in_dim, out_dim)
80
+
81
+ def forward(self, x):
82
+ if x.shape[0] <= 1:
83
+ self.train(False)
84
+ residual = self.skip(x)
85
+ x = self.layer1(x)
86
+ x = self.bn1(x)
87
+ x = self.act(x)
88
+ x = self.dropout(x)
89
+ x = self.layer2(x)
90
+ x = self.bn2(x)
91
+ return F.normalize(x + residual, p=2, dim=1)
92
+
93
+
94
+ class IconClassifier(nn.Module):
95
+ def __init__(self, backbone, head_state, num_classes=21):
96
+ super().__init__()
97
+ self.backbone = backbone
98
+ self.projection = ProjectionHead(1024, 512, 256)
99
+ self.weight = nn.Parameter(head_state['arc_margin']['weight'])
100
+ self.projection.load_state_dict(head_state['projection'])
101
+ self.s = 30.0
102
+
103
+ def forward(self, x):
104
+ features = self.backbone(x)
105
+ embedding = self.projection(features)
106
+ logits = F.linear(F.normalize(embedding), F.normalize(self.weight))
107
+ return logits * self.s
108
+
109
+ def get_embedding(self, x):
110
+ features = self.backbone(x)
111
+ embedding = self.projection(features)
112
+ return F.normalize(embedding, p=2, dim=1)
113
+
114
+
115
+ # ═══════════════════════════════════════════════════════════════
116
+ # PREPROCESSING (ImageNet β€” sesuai training)
117
+ # ═══════════════════════════════════════════════════════════════
118
+ custom_preprocess = T.Compose([
119
+ T.Resize((256, 256), interpolation=T.InterpolationMode.NEAREST),
120
+ T.ToTensor(),
121
+ T.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
122
+ ])
123
+
124
+
125
+ # ═══════════════════════════════════════════════════════════════
126
+ # IMAGE DECODE
127
+ # ═══════════════════════════════════════════════════════════════
128
+ def decode_image_advanced(data: Union[str, bytes], filename: str = "image") -> Image.Image:
129
+ if isinstance(data, str):
130
+ clean = data.strip().replace("\n", "").replace("\r", "").replace(" ", "")
131
+ clean = re.sub(r"^data:image/[a-zA-Z0-9.+-]+;base64,", "", clean)
132
+ missing = len(clean) % 4
133
+ if missing:
134
+ clean += '=' * (4 - missing)
135
+ try:
136
+ raw_bytes = base64.b64decode(clean, validate=False)
137
+ except Exception as e:
138
+ raise ValueError(f"Base64 decode failed: {e}")
139
+ else:
140
+ raw_bytes = data
141
+
142
+ try:
143
+ img = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
144
+ return img
145
+ except Exception:
146
+ pass
147
+
148
+ common_sizes = [50, 60, 64, 70, 80, 96, 100, 128]
149
+ for size in common_sizes:
150
+ expected_bytes = size * size * 4
151
+ if len(raw_bytes) >= expected_bytes:
152
+ try:
153
+ arr = np.frombuffer(raw_bytes[:expected_bytes], dtype=np.uint8)
154
+ arr = arr.reshape((size, size, 4))
155
+ rgb_arr = arr[:, :, :3]
156
+ return Image.fromarray(rgb_arr)
157
+ except Exception:
158
+ continue
159
+
160
+ raise ValueError(f"Cannot decode {filename}")
161
+
162
+
163
+ # ═══════════════════════════════════════════════════════════════
164
+ # MODEL MANAGER
165
+ # ═══════════════════════════════════════════════════════════════
166
+ class ModelManager:
167
+ _instance = None
168
+
169
+ def __new__(cls):
170
+ if cls._instance is None:
171
+ cls._instance = super().__new__(cls)
172
+ cls._instance.initialized = False
173
+ return cls._instance
174
+
175
+ def init(self):
176
+ if self.initialized:
177
+ return
178
+
179
+ logger.info("=" * 60)
180
+ logger.info("πŸ”₯ INITIALIZING TRIPLE HYBRID API + Gradio")
181
+ logger.info("=" * 60)
182
+
183
+ logger.info(f"πŸ“¦ Loading YOLO: {CONFIG.yolo_model_path}")
184
+ self.yolo = YOLO(CONFIG.yolo_model_path)
185
+ self.yolo.to(CONFIG.device)
186
+ logger.info("βœ… YOLO loaded")
187
+
188
+ logger.info(f"πŸ“¦ Loading Hybrid LoRA from: {CONFIG.lora_model_dir}")
189
+
190
+ config_path = Path(CONFIG.lora_model_dir) / "config.json"
191
+ with open(config_path) as f:
192
+ self.model_config = json.load(f)
193
+
194
+ num_classes = self.model_config.get('num_classes')
195
+ if num_classes is None:
196
+ class_to_idx = self.model_config.get('class_to_idx', {})
197
+ num_classes = len(class_to_idx)
198
+
199
+ model_name = self.model_config.get('model_name', CONFIG.model_name)
200
+ logger.info(f" Config: {num_classes} classes, {model_name}")
201
+
202
+ backbone, _, _ = open_clip.create_model_and_transforms(model_name, pretrained='webli')
203
+ backbone = backbone.visual.to(CONFIG.device)
204
+ model_with_lora = PeftModel.from_pretrained(backbone, CONFIG.lora_model_dir)
205
+
206
+ head_path = Path(CONFIG.lora_model_dir) / "head_weights.pt"
207
+ head_state = torch.load(head_path, map_location=CONFIG.device)
208
+
209
+ self.inference_model = IconClassifier(model_with_lora, head_state, num_classes).to(CONFIG.device).eval()
210
+ logger.info("βœ… Hybrid LoRA loaded")
211
+
212
+ self.idx_to_class = {int(k): v for k, v in self.model_config.get('idx_to_class', {}).items()}
213
+ self.class_names = [self.idx_to_class.get(i, f"class_{i}") for i in range(num_classes)]
214
+
215
+ self.initialized = True
216
+ logger.info("=" * 60)
217
+ logger.info("πŸš€ ALL MODELS READY!")
218
+ logger.info("=" * 60)
219
+
220
+ def yolo_detect_and_crop(self, image: Image.Image) -> Image.Image:
221
+ img_np = np.array(image)
222
+ img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
223
+ results = self.yolo(img_bgr, conf=CONFIG.yolo_conf, imgsz=CONFIG.yolo_img_size, verbose=False)
224
+
225
+ best_box = None
226
+ best_conf = 0
227
+ for result in results:
228
+ if result.boxes is None:
229
+ continue
230
+ for box in result.boxes:
231
+ conf = float(box.conf[0])
232
+ if conf > best_conf:
233
+ best_conf = conf
234
+ best_box = box.xyxy[0].cpu().numpy().astype(int)
235
+
236
+ if best_box is not None:
237
+ x1, y1, x2, y2 = best_box
238
+ h, w = img_np.shape[:2]
239
+ x1, y1 = max(0, x1), max(0, y1)
240
+ x2, y2 = min(w, x2), min(h, y2)
241
+ crop_w = x2 - x1
242
+ crop_h = y2 - y1
243
+
244
+ if best_conf < CONFIG.yolo_min_conf:
245
+ logger.warning(f"YOLO conf too low ({best_conf:.3f}), using full image")
246
+ return image
247
+ if crop_w < CONFIG.yolo_min_crop_size or crop_h < CONFIG.yolo_min_crop_size:
248
+ logger.warning(f"YOLO crop too small ({crop_w}x{crop_h}), using full image")
249
+ return image
250
+
251
+ logger.info(f"YOLO crop: ({x1},{y1})β†’({x2},{y2}) [{crop_w}x{crop_h}], conf={best_conf:.3f}")
252
+ return Image.fromarray(img_np[y1:y2, x1:x2])
253
+ else:
254
+ logger.warning("YOLO: No detection, using full image")
255
+ return image
256
+
257
+ def extract_embedding(self, image: Image.Image) -> torch.Tensor:
258
+ img_tensor = custom_preprocess(image).unsqueeze(0).to(CONFIG.device)
259
+ with torch.no_grad():
260
+ with autocast():
261
+ emb = self.inference_model.get_embedding(img_tensor)
262
+ return emb
263
+
264
+ def match_options(self, challenge_crop: Image.Image, options: List[Image.Image]) -> dict:
265
+ challenge_emb = self.extract_embedding(challenge_crop)
266
+ option_embs = []
267
+ for opt_img in options:
268
+ emb = self.extract_embedding(opt_img)
269
+ option_embs.append(emb)
270
+
271
+ option_stack = torch.cat(option_embs, dim=0)
272
+ similarities = (challenge_emb @ option_stack.T).squeeze(0).cpu().numpy()
273
+
274
+ best_idx = int(np.argmax(similarities))
275
+ best_score = float(similarities[best_idx])
276
+
277
+ exp_sim = np.exp(similarities * 10)
278
+ probs = exp_sim / exp_sim.sum()
279
+
280
+ return {
281
+ "predicted_index": best_idx,
282
+ "confidence": float(probs[best_idx]),
283
+ "similarity_score": best_score,
284
+ "all_scores": {
285
+ f"option_{i}": {
286
+ "similarity": float(similarities[i]),
287
+ "probability": float(probs[i])
288
+ } for i in range(len(options))
289
+ },
290
+ "predicted_class": self.idx_to_class.get(best_idx, "unknown")
291
+ }
292
+
293
+
294
+ # ═══════════════════════════════════════════════════════════════
295
+ # GRADIO FUNCTION (DITAMBAHKAN @spaces.GPU)
296
+ # ═══════════════════════════════════════════════════════════════
297
+ @spaces.GPU() # ← INI YANG DIPERLUKAN AGAR HF Spaces mendeteksi GPU usage
298
+ def solve_gradio(challenge_img, opt1, opt2, opt3, opt4, opt5, opt6, opt7, opt8):
299
+ """
300
+ Gradio interface function.
301
+ Input: 9 images (1 challenge + 8 options)
302
+ Output: Result text + highlighted best option
303
+ """
304
+ manager = ModelManager()
305
+ if not manager.initialized:
306
+ return "Error: Model not initialized", None
307
+
308
+ try:
309
+ options = [opt1, opt2, opt3, opt4, opt5, opt6, opt7, opt8]
310
+
311
+ # Validate
312
+ if challenge_img is None:
313
+ return "Error: Challenge image required", None
314
+ if any(o is None for o in options):
315
+ return "Error: All 8 options required", None
316
+
317
+ # Convert numpy/PIL
318
+ if isinstance(challenge_img, np.ndarray):
319
+ challenge_img = Image.fromarray(challenge_img)
320
+
321
+ options_pil = []
322
+ for i, opt in enumerate(options):
323
+ if isinstance(opt, np.ndarray):
324
+ opt = Image.fromarray(opt)
325
+ options_pil.append(opt)
326
+
327
+ # YOLO crop
328
+ challenge_crop = manager.yolo_detect_and_crop(challenge_img)
329
+
330
+ # Match
331
+ result = manager.match_options(challenge_crop, options_pil)
332
+
333
+ # Format output
334
+ best_idx = result["predicted_index"]
335
+ confidence = result["confidence"]
336
+ predicted_class = result["predicted_class"]
337
+
338
+ lines = [
339
+ "",
340
+ "🎯 RESULT",
341
+ "",
342
+ f"Best Option: {best_idx + 1} (index {best_idx})",
343
+ f"Confidence: {confidence:.4f} ({confidence*100:.2f}%)",
344
+ f"Predicted Class: {predicted_class}",
345
+ f"Similarity Score: {result['similarity_score']:.4f}",
346
+ "",
347
+ "πŸ“Š All Scores:",
348
+ "",
349
+ ]
350
+
351
+ for i in range(8):
352
+ score = result["all_scores"][f"option_{i}"]
353
+ marker = "βœ…" if i == best_idx else " "
354
+ lines.append(f"{marker} Option {i+1}: {score['probability']:.4f} ({score['similarity']:.4f})")
355
+
356
+ output_text = "\n".join(lines)
357
+
358
+ # Return best option image
359
+ best_image = options_pil[best_idx]
360
+
361
+ gc.collect()
362
+ if torch.cuda.is_available():
363
+ torch.cuda.empty_cache()
364
+
365
+ return output_text, best_image
366
+
367
+ except Exception as e:
368
+ logger.error(f"Gradio error: {e}", exc_info=True)
369
+ return f"Error: {str(e)}", None
370
+
371
+
372
+ # ═══════════════════════════════════════════════════════════════
373
+ # BUILD GRADIO INTERFACE
374
+ # ═══════════════════════════════════════════════════════════════
375
+ def build_gradio_interface() -> gr.Blocks:
376
+ with gr.Blocks(title="πŸ”₯ Icon Challenge Solver", theme=gr.themes.Soft()) as demo:
377
+ gr.Markdown("""
378
+ # πŸ”₯ Icon Challenge Solver
379
+ ### YOLO + SigLIP LoRA Hybrid
380
+
381
+ Upload 1 challenge image + 8 option images. The AI will:
382
+ 1. **YOLO**: Detect & crop the icon from challenge
383
+ 2. **SigLIP LoRA**: Match the crop with 8 options
384
+ 3. Return the best matching option
385
+ """)
386
+
387
+ with gr.Row():
388
+ with gr.Column(scale=1):
389
+ gr.Markdown("### 🎯 Challenge Image")
390
+ challenge_input = gr.Image(
391
+ label="Challenge (1 image)",
392
+ type="numpy",
393
+ height=200
394
+ )
395
+
396
+ with gr.Column(scale=2):
397
+ gr.Markdown("### 🎲 Option Images (8 total)")
398
+ with gr.Row():
399
+ opt1 = gr.Image(label="Option 1", type="numpy", height=150)
400
+ opt2 = gr.Image(label="Option 2", type="numpy", height=150)
401
+ opt3 = gr.Image(label="Option 3", type="numpy", height=150)
402
+ opt4 = gr.Image(label="Option 4", type="numpy", height=150)
403
+ with gr.Row():
404
+ opt5 = gr.Image(label="Option 5", type="numpy", height=150)
405
+ opt6 = gr.Image(label="Option 6", type="numpy", height=150)
406
+ opt7 = gr.Image(label="Option 7", type="numpy", height=150)
407
+ opt8 = gr.Image(label="Option 8", type="numpy", height=150)
408
+
409
+ with gr.Row():
410
+ solve_btn = gr.Button("πŸ” Solve Challenge", variant="primary", size="lg")
411
+
412
+ with gr.Row():
413
+ with gr.Column(scale=1):
414
+ gr.Markdown("### πŸ“Š Result")
415
+ result_text = gr.Textbox(
416
+ label="Analysis",
417
+ lines=15,
418
+ max_lines=20,
419
+ interactive=False
420
+ )
421
+ with gr.Column(scale=1):
422
+ gr.Markdown("### βœ… Best Match")
423
+ best_image = gr.Image(label="Best Option", height=200)
424
+
425
+ # Bind button
426
+ solve_btn.click(
427
+ fn=solve_gradio,
428
+ inputs=[challenge_input, opt1, opt2, opt3, opt4, opt5, opt6, opt7, opt8],
429
+ outputs=[result_text, best_image]
430
+ )
431
+
432
+ gr.Markdown("""
433
+ ---
434
+ **API Endpoint:** `POST /solve` (JSON or multipart)
435
+ **Health Check:** `GET /health`
436
+ """)
437
+
438
+ return demo
439
+
440
+
441
+ # ═══════════════════════════════════════════════════════════════
442
+ # FASTAPI APP
443
+ # ═══════════════════════════════════════════════════════════════
444
+ @asynccontextmanager
445
+ async def lifespan(app: FastAPI):
446
+ manager = ModelManager()
447
+ manager.init()
448
+ yield
449
+ logger.info("Shutting down...")
450
+ gc.collect()
451
+ torch.cuda.empty_cache()
452
+
453
+ app = FastAPI(
454
+ title="Triple Hybrid API + Gradio: YOLO + SigLIP LoRA",
455
+ version="4.0.0",
456
+ lifespan=lifespan
457
+ )
458
+
459
+ app.add_middleware(
460
+ CORSMiddleware,
461
+ allow_origins=["*"],
462
+ allow_credentials=True,
463
+ allow_methods=["*"],
464
+ allow_headers=["*"],
465
+ )
466
+
467
+
468
+ class SolveRequest(BaseModel):
469
+ challenge: str
470
+ options: List[str]
471
+
472
+
473
+ @app.get("/")
474
+ async def root():
475
+ return {
476
+ "message": "Triple Hybrid API + Gradio: YOLO + SigLIP LoRA",
477
+ "endpoints": {
478
+ "solve": "POST /solve",
479
+ "health": "GET /health",
480
+ "gradio": "GET /gradio"
481
+ },
482
+ "preprocessing": "mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)"
483
+ }
484
+
485
+ @app.get("/health")
486
+ async def health_check():
487
+ manager = ModelManager()
488
+ return {
489
+ "status": "healthy",
490
+ "yolo_loaded": hasattr(manager, 'yolo') and manager.yolo is not None,
491
+ "hybrid_loaded": hasattr(manager, 'inference_model') and manager.inference_model is not None,
492
+ "device": CONFIG.device,
493
+ "num_classes": len(manager.idx_to_class) if hasattr(manager, 'idx_to_class') else 0
494
+ }
495
+
496
+ @app.post("/solve")
497
+ async def solve(request: Request):
498
+ manager = ModelManager()
499
+ content_type = request.headers.get("content-type", "").lower()
500
+ challenge_img: Optional[Image.Image] = None
501
+ option_imgs: List[Image.Image] = []
502
+
503
+ try:
504
+ if "application/json" in content_type:
505
+ body = await request.json()
506
+ challenge_input = body.get("challenge")
507
+ options_input = body.get("options", [])
508
+
509
+ if not challenge_input:
510
+ raise HTTPException(400, "'challenge' wajib diisi.")
511
+ if not isinstance(options_input, list) or len(options_input) != 8:
512
+ raise HTTPException(400, "'options' harus list berisi tepat 8 base64 images.")
513
+
514
+ challenge_img = decode_image_advanced(challenge_input, "challenge")
515
+ option_imgs = [decode_image_advanced(o, f"option_{i}") for i, o in enumerate(options_input)]
516
+
517
+ elif "multipart/form-data" in content_type:
518
+ form = await request.form()
519
+
520
+ challenge_item = form.get("challenge")
521
+ if challenge_item is None:
522
+ raise HTTPException(400, "Field 'challenge' wajib diisi.")
523
+
524
+ if hasattr(challenge_item, "read"):
525
+ content = await challenge_item.read()
526
+ challenge_img = decode_image_advanced(content, "challenge")
527
+ else:
528
+ challenge_img = decode_image_advanced(challenge_item, "challenge")
529
+
530
+ options_items = form.getlist("options") or []
531
+ if len(options_items) != 8:
532
+ options_items = []
533
+ for i in range(1, 9):
534
+ item = form.get(f"option_{i}")
535
+ if item is None:
536
+ break
537
+ options_items.append(item)
538
+
539
+ if len(options_items) != 8:
540
+ raise HTTPException(400, f"Harus ada tepat 8 options, ditemukan {len(options_items)}.")
541
+
542
+ for i, item in enumerate(options_items):
543
+ try:
544
+ if hasattr(item, "read"):
545
+ content = await item.read()
546
+ option_imgs.append(decode_image_advanced(content, f"option_{i}"))
547
+ else:
548
+ option_imgs.append(decode_image_advanced(item, f"option_{i}"))
549
+ except ValueError as e:
550
+ raise HTTPException(400, f"Error option_{i+1}: {str(e)}")
551
+ else:
552
+ raise HTTPException(415, "Content-Type tidak didukung.")
553
+
554
+ logger.info(f"πŸ“₯ Received: challenge({challenge_img.size}) + {len(option_imgs)} options")
555
+
556
+ challenge_crop = await run_in_threadpool(manager.yolo_detect_and_crop, challenge_img)
557
+ result = await run_in_threadpool(manager.match_options, challenge_crop, option_imgs)
558
+
559
+ logger.info(f"βœ… Result: Option {result['predicted_index']} (conf: {result['confidence']:.4f})")
560
+
561
+ gc.collect()
562
+ if torch.cuda.is_available():
563
+ torch.cuda.empty_cache()
564
+
565
+ return {
566
+ "success": True,
567
+ "predicted_index": result["predicted_index"],
568
+ "confidence": result["confidence"],
569
+ "similarity_score": result["similarity_score"],
570
+ "predicted_class": result["predicted_class"],
571
+ "all_scores": result["all_scores"],
572
+ "debug": {
573
+ "challenge_crop_size": challenge_crop.size,
574
+ "device": CONFIG.device,
575
+ "preprocessing": "mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)"
576
+ }
577
+ }
578
+
579
+ except HTTPException:
580
+ raise
581
+ except ValueError as e:
582
+ raise HTTPException(400, str(e))
583
+ except Exception as e:
584
+ logger.error(f"Unexpected error: {e}", exc_info=True)
585
+ raise HTTPException(500, f"Internal error: {str(e)}")
586
+
587
+
588
+ # ═══════════════════════════════════════════════════════════════
589
+ # MOUNT GRADIO TO FASTAPI
590
+ # ═══════════════════════════════════════════════════════════════
591
+ gradio_app = build_gradio_interface()
592
+ app = gr.mount_gradio_app(app, gradio_app, path="/gradio")
593
+
594
+
595
+ if __name__ == "__main__":
596
+ import uvicorn
597
+ uvicorn.run(app, host="0.0.0.0", port=7860)