SandiyoS commited on
Commit
6acda67
·
verified ·
1 Parent(s): 2e6bc8e

Delete app.py

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