doniramdani820 commited on
Commit
d27713e
Β·
verified Β·
1 Parent(s): 43933c6

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +763 -703
app.py CHANGED
@@ -1,703 +1,763 @@
1
- """
2
- πŸš€ FunCaptcha Solver API - Hugging Face Spaces Deployment
3
- Optimized for speed, memory efficiency, and scalability
4
-
5
- Features:
6
- - FastAPI async operations
7
- - API key authentication via HF secrets
8
- - Fuzzy label matching
9
- - Memory-efficient model loading
10
- - ONNX CPU optimization
11
- - Request caching for performance
12
- """
13
-
14
- import os
15
- import io
16
- import base64
17
- import hashlib
18
- import asyncio
19
- from datetime import datetime
20
- from typing import Optional, Dict, Any, List
21
- import logging
22
-
23
- import cv2
24
- import numpy as np
25
- from PIL import Image
26
- import yaml
27
- import difflib
28
-
29
- # Try to import ML backends dengan multiple fallbacks
30
- ONNX_AVAILABLE = False
31
- TORCH_AVAILABLE = False
32
- TF_AVAILABLE = False
33
- ort = None
34
-
35
- # Try ONNX Runtime first
36
- try:
37
- import onnxruntime as ort
38
- ONNX_AVAILABLE = True
39
- print("βœ… ONNX Runtime imported successfully")
40
- except ImportError as e:
41
- print(f"❌ ONNX Runtime import failed: {e}")
42
-
43
- # Try PyTorch as fallback
44
- try:
45
- import torch
46
- TORCH_AVAILABLE = True
47
- print("βœ… PyTorch imported as ONNX Runtime alternative")
48
- except ImportError:
49
- print("❌ PyTorch not available")
50
-
51
- # Try TensorFlow as final fallback
52
- try:
53
- import tensorflow as tf
54
- TF_AVAILABLE = True
55
- print("βœ… TensorFlow imported as ONNX Runtime alternative")
56
- except ImportError:
57
- print("❌ TensorFlow not available")
58
- print("⚠️ Running without ML backend - model inference will be disabled")
59
-
60
- ML_BACKEND_AVAILABLE = ONNX_AVAILABLE or TORCH_AVAILABLE or TF_AVAILABLE
61
-
62
- from fastapi import FastAPI, HTTPException, Depends, status
63
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
64
- from fastapi.middleware.cors import CORSMiddleware
65
- from pydantic import BaseModel, Field
66
- import uvicorn
67
-
68
- # Configure logging
69
- logging.basicConfig(level=logging.INFO)
70
- logger = logging.getLogger(__name__)
71
-
72
- # =================================================================
73
- # CONFIGURATION & MODELS
74
- # =================================================================
75
-
76
- class FunCaptchaRequest(BaseModel):
77
- """Request model untuk FunCaptcha solving"""
78
- challenge_type: str = Field(..., description="Type of challenge (pick_the, upright)")
79
- image_b64: str = Field(..., description="Base64 encoded image")
80
- target_label: Optional[str] = Field(None, description="Target label untuk pick_the challenges")
81
-
82
- class FunCaptchaResponse(BaseModel):
83
- """Response model untuk FunCaptcha solving"""
84
- status: str = Field(..., description="Status: success, not_found, error")
85
- box: Optional[List[float]] = Field(None, description="Bounding box coordinates [x, y, w, h]")
86
- button_index: Optional[int] = Field(None, description="Button index untuk upright challenges")
87
- confidence: Optional[float] = Field(None, description="Detection confidence")
88
- message: Optional[str] = Field(None, description="Additional message")
89
- processing_time: Optional[float] = Field(None, description="Processing time in seconds")
90
-
91
- # =================================================================
92
- # AUTHENTICATION
93
- # =================================================================
94
-
95
- security = HTTPBearer()
96
-
97
- def get_api_key_from_secrets() -> str:
98
- """Get API key dari Hugging Face Secrets"""
99
- api_key = os.getenv("FUNCAPTCHA_API_KEY")
100
- if not api_key:
101
- logger.error("FUNCAPTCHA_API_KEY not found in environment variables")
102
- raise ValueError("API key tidak ditemukan dalam HF Secrets")
103
- return api_key
104
-
105
- def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)) -> bool:
106
- """Verify API key dari request header"""
107
- expected_key = get_api_key_from_secrets()
108
- if credentials.credentials != expected_key:
109
- raise HTTPException(
110
- status_code=status.HTTP_401_UNAUTHORIZED,
111
- detail="Invalid API key",
112
- headers={"WWW-Authenticate": "Bearer"}
113
- )
114
- return True
115
-
116
- # =================================================================
117
- # MODEL CONFIGURATION & MANAGEMENT
118
- # =================================================================
119
-
120
- CONFIGS = {
121
- 'default': {
122
- 'model_path': 'best.onnx',
123
- 'yaml_path': 'data.yaml',
124
- 'input_size': 640,
125
- 'confidence_threshold': 0.4,
126
- 'nms_threshold': 0.2
127
- },
128
- 'spiral_galaxy': {
129
- 'model_path': 'bestspiral.onnx',
130
- 'yaml_path': 'dataspiral.yaml',
131
- 'input_size': 416,
132
- 'confidence_threshold': 0.30,
133
- 'nms_threshold': 0.45
134
- },
135
- 'upright': {
136
- 'model_path': 'best_Upright.onnx',
137
- 'yaml_path': 'data_upright.yaml',
138
- 'input_size': 640,
139
- 'confidence_threshold': 0.45,
140
- 'nms_threshold': 0.45
141
- }
142
- }
143
-
144
- MODEL_ROUTING = [
145
- (['spiral', 'galaxy'], 'spiral_galaxy')
146
- ]
147
-
148
- # Global cache untuk models dan responses
149
- LOADED_MODELS: Dict[str, Dict[str, Any]] = {}
150
- RESPONSE_CACHE: Dict[str, Dict[str, Any]] = {}
151
- CACHE_MAX_SIZE = 100
152
-
153
- class ModelManager:
154
- """Manager untuk loading dan caching models"""
155
-
156
- @staticmethod
157
- async def get_model(config_key: str) -> Optional[Dict[str, Any]]:
158
- """Load model dengan caching untuk efficiency"""
159
- # Check if any ML backend is available
160
- if not ML_BACKEND_AVAILABLE:
161
- logger.error("❌ No ML backend available - cannot load models")
162
- return None
163
-
164
- if config_key not in LOADED_MODELS:
165
- logger.info(f"Loading model: {config_key}")
166
-
167
- try:
168
- config = CONFIGS[config_key]
169
-
170
- # Check if files exist
171
- if not os.path.exists(config['model_path']):
172
- logger.warning(f"Model file not found: {config['model_path']}")
173
- return None
174
-
175
- if not os.path.exists(config['yaml_path']):
176
- logger.warning(f"YAML file not found: {config['yaml_path']}")
177
- return None
178
-
179
- # Load model dengan available backend
180
- session = None
181
-
182
- if ONNX_AVAILABLE:
183
- # Load ONNX session dengan CPU optimization
184
- providers = ['CPUExecutionProvider']
185
- session_options = ort.SessionOptions()
186
- session_options.intra_op_num_threads = 2 # Optimize untuk CPU
187
- session_options.inter_op_num_threads = 2
188
- session_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
189
- session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
190
-
191
- session = ort.InferenceSession(
192
- config['model_path'],
193
- providers=providers,
194
- sess_options=session_options
195
- )
196
- else:
197
- # For now, only ONNX Runtime is supported for model loading
198
- # PyTorch/TensorFlow alternatives would need model conversion
199
- logger.error("❌ ONNX models require ONNX Runtime - other backends not yet implemented")
200
- return None
201
-
202
- # Load class names
203
- with open(config['yaml_path'], 'r', encoding='utf-8') as file:
204
- class_names = yaml.safe_load(file)['names']
205
-
206
- LOADED_MODELS[config_key] = {
207
- 'session': session,
208
- 'class_names': class_names,
209
- 'input_name': session.get_inputs()[0].name,
210
- 'input_size': config['input_size'],
211
- 'confidence': config['confidence_threshold'],
212
- 'nms': config.get('nms_threshold', 0.45)
213
- }
214
-
215
- logger.info(f"βœ… Model loaded successfully: {config_key}")
216
-
217
- except Exception as e:
218
- logger.error(f"❌ Error loading model {config_key}: {e}")
219
- return None
220
-
221
- return LOADED_MODELS[config_key]
222
-
223
- # =================================================================
224
- # IMAGE PROCESSING & UTILITIES
225
- # =================================================================
226
-
227
- def preprocess_image(image_bytes: bytes, input_size: int) -> np.ndarray:
228
- """Preprocess image untuk ONNX inference dengan optimasi memory"""
229
- image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
230
- image_np = np.array(image)
231
- h, w, _ = image_np.shape
232
-
233
- scale = min(input_size / w, input_size / h)
234
- new_w, new_h = int(w * scale), int(h * scale)
235
-
236
- resized_image = cv2.resize(image_np, (new_w, new_h))
237
- padded_image = np.full((input_size, input_size, 3), 114, dtype=np.uint8)
238
-
239
- # Calculate padding
240
- y_offset = (input_size - new_h) // 2
241
- x_offset = (input_size - new_w) // 2
242
-
243
- padded_image[y_offset:y_offset + new_h, x_offset:x_offset + new_w, :] = resized_image
244
-
245
- # Convert untuk ONNX
246
- input_tensor = padded_image.astype(np.float32) / 255.0
247
- input_tensor = np.transpose(input_tensor, (2, 0, 1))
248
- input_tensor = np.expand_dims(input_tensor, axis=0)
249
-
250
- return input_tensor
251
-
252
- def fuzzy_match_label(target_label: str, class_names: List[str], threshold: float = 0.6) -> Optional[str]:
253
- """Fuzzy matching untuk label variations"""
254
- target_normalized = target_label.lower().strip()
255
-
256
- # Dictionary untuk common variations
257
- label_variants = {
258
- 'ice cream': ['ice cream', 'icecream', 'ice'],
259
- 'hotdog': ['hot dog', 'hotdog', 'hot-dog'],
260
- 'hot dog': ['hot dog', 'hotdog', 'hot-dog'],
261
- 'sunglasses': ['sunglasses', 'sun glasses', 'sunglass'],
262
- 'sun glasses': ['sunglasses', 'sun glasses', 'sunglass']
263
- }
264
-
265
- # 1. Exact match
266
- if target_normalized in class_names:
267
- return target_normalized
268
-
269
- # 2. Check known variants
270
- for main_label, variants in label_variants.items():
271
- if target_normalized in variants and main_label in class_names:
272
- return main_label
273
-
274
- # 3. Fuzzy matching
275
- best_matches = difflib.get_close_matches(
276
- target_normalized,
277
- [name.lower() for name in class_names],
278
- n=3,
279
- cutoff=threshold
280
- )
281
-
282
- if best_matches:
283
- for match in best_matches:
284
- for class_name in class_names:
285
- if class_name.lower() == match:
286
- return class_name
287
-
288
- # 4. Partial matching
289
- for class_name in class_names:
290
- if target_normalized in class_name.lower() or class_name.lower() in target_normalized:
291
- return class_name
292
-
293
- return None
294
-
295
- def get_config_key_for_label(target_label: str) -> str:
296
- """Determine which model config to use"""
297
- for keywords, config_key in MODEL_ROUTING:
298
- if any(keyword in target_label for keyword in keywords):
299
- return config_key
300
- return 'default'
301
-
302
- def get_button_index(x_center: float, y_center: float, img_width: int, img_height: int,
303
- grid_cols: int = 3, grid_rows: int = 2) -> int:
304
- """Calculate button index dari coordinates"""
305
- col = int(x_center // (img_width / grid_cols))
306
- row = int(y_center // (img_height / grid_rows))
307
- return row * grid_cols + col + 1
308
-
309
- # =================================================================
310
- # CACHING SYSTEM
311
- # =================================================================
312
-
313
- def get_cache_key(request_data: dict) -> str:
314
- """Generate cache key dari request data"""
315
- cache_string = f"{request_data.get('challenge_type')}_{request_data.get('target_label')}_{request_data.get('image_b64', '')[:100]}"
316
- return hashlib.md5(cache_string.encode()).hexdigest()
317
-
318
- def get_cached_response(cache_key: str) -> Optional[dict]:
319
- """Get response dari cache jika ada"""
320
- return RESPONSE_CACHE.get(cache_key)
321
-
322
- def cache_response(cache_key: str, response: dict):
323
- """Cache response dengan size limit"""
324
- if len(RESPONSE_CACHE) >= CACHE_MAX_SIZE:
325
- # Remove oldest entry
326
- oldest_key = next(iter(RESPONSE_CACHE))
327
- del RESPONSE_CACHE[oldest_key]
328
-
329
- RESPONSE_CACHE[cache_key] = response
330
-
331
- # =================================================================
332
- # CHALLENGE HANDLERS
333
- # =================================================================
334
-
335
- async def handle_pick_the_challenge(data: dict) -> dict:
336
- """Handle 'pick the' challenges dengan fuzzy matching"""
337
- start_time = datetime.now()
338
-
339
- target_label_original = data['target_label']
340
- image_b64 = data['image_b64']
341
- target_label = target_label_original
342
-
343
- config_key = get_config_key_for_label(target_label)
344
-
345
- if config_key == 'spiral_galaxy':
346
- target_label = 'spiral'
347
-
348
- model_data = await ModelManager.get_model(config_key)
349
- if not model_data:
350
- if not ML_BACKEND_AVAILABLE:
351
- return {
352
- 'status': 'error',
353
- 'message': 'No ML backend available - model inference disabled',
354
- 'processing_time': (datetime.now() - start_time).total_seconds()
355
- }
356
- return {
357
- 'status': 'error',
358
- 'message': f'Model {config_key} tidak ditemukan',
359
- 'processing_time': (datetime.now() - start_time).total_seconds()
360
- }
361
-
362
- try:
363
- # Decode image
364
- image_bytes = base64.b64decode(image_b64.split(',')[1])
365
-
366
- # Fuzzy matching untuk label
367
- matched_label = fuzzy_match_label(target_label, model_data['class_names'])
368
- if not matched_label:
369
- return {
370
- 'status': 'not_found',
371
- 'message': f'Label "{target_label}" tidak ditemukan dalam model',
372
- 'processing_time': (datetime.now() - start_time).total_seconds()
373
- }
374
-
375
- target_label = matched_label
376
-
377
- # Preprocessing
378
- input_tensor = preprocess_image(image_bytes, model_data['input_size'])
379
-
380
- # Inference
381
- outputs = model_data['session'].run(None, {model_data['input_name']: input_tensor})[0]
382
- predictions = np.squeeze(outputs).T
383
-
384
- # Process detections
385
- boxes = []
386
- confidences = []
387
- class_ids = []
388
-
389
- for pred in predictions:
390
- class_scores = pred[4:]
391
- class_id = np.argmax(class_scores)
392
- max_confidence = class_scores[class_id]
393
-
394
- if max_confidence > model_data['confidence']:
395
- confidences.append(float(max_confidence))
396
- class_ids.append(class_id)
397
- box_model = pred[:4]
398
- x_center, y_center, width, height = box_model
399
- x1 = x_center - width / 2
400
- y1 = y_center - height / 2
401
- boxes.append([int(x1), int(y1), int(width), int(height)])
402
-
403
- if not boxes:
404
- return {
405
- 'status': 'not_found',
406
- 'processing_time': (datetime.now() - start_time).total_seconds()
407
- }
408
-
409
- # Non-Maximum Suppression
410
- indices = cv2.dnn.NMSBoxes(
411
- np.array(boxes),
412
- np.array(confidences),
413
- model_data['confidence'],
414
- model_data['nms']
415
- )
416
-
417
- if len(indices) == 0:
418
- return {
419
- 'status': 'not_found',
420
- 'processing_time': (datetime.now() - start_time).total_seconds()
421
- }
422
-
423
- # Find target
424
- target_class_id = model_data['class_names'].index(target_label)
425
- best_match_box = None
426
- highest_score = 0
427
-
428
- for i in indices.flatten():
429
- if class_ids[i] == target_class_id:
430
- current_score = confidences[i]
431
- if current_score > highest_score:
432
- highest_score = current_score
433
- best_match_box = boxes[i]
434
-
435
- if best_match_box is not None:
436
- # Scale back to original coordinates
437
- img = Image.open(io.BytesIO(image_bytes))
438
- original_w, original_h = img.size
439
- scale = min(model_data['input_size'] / original_w, model_data['input_size'] / original_h)
440
- pad_x = (model_data['input_size'] - original_w * scale) / 2
441
- pad_y = (model_data['input_size'] - original_h * scale) / 2
442
-
443
- x_orig = (best_match_box[0] - pad_x) / scale
444
- y_orig = (best_match_box[1] - pad_y) / scale
445
- w_orig = best_match_box[2] / scale
446
- h_orig = best_match_box[3] / scale
447
-
448
- return {
449
- 'status': 'success',
450
- 'box': [x_orig, y_orig, w_orig, h_orig],
451
- 'confidence': highest_score,
452
- 'processing_time': (datetime.now() - start_time).total_seconds()
453
- }
454
-
455
- except Exception as e:
456
- logger.error(f"Error in handle_pick_the_challenge: {e}")
457
- return {
458
- 'status': 'error',
459
- 'message': str(e),
460
- 'processing_time': (datetime.now() - start_time).total_seconds()
461
- }
462
-
463
- return {
464
- 'status': 'not_found',
465
- 'processing_time': (datetime.now() - start_time).total_seconds()
466
- }
467
-
468
- async def handle_upright_challenge(data: dict) -> dict:
469
- """Handle 'upright' challenges"""
470
- start_time = datetime.now()
471
-
472
- try:
473
- image_b64 = data['image_b64']
474
- model_data = await ModelManager.get_model('upright')
475
-
476
- if not model_data:
477
- if not ML_BACKEND_AVAILABLE:
478
- return {
479
- 'status': 'error',
480
- 'message': 'No ML backend available - model inference disabled',
481
- 'processing_time': (datetime.now() - start_time).total_seconds()
482
- }
483
- return {
484
- 'status': 'error',
485
- 'message': 'Model upright tidak ditemukan',
486
- 'processing_time': (datetime.now() - start_time).total_seconds()
487
- }
488
-
489
- image_bytes = base64.b64decode(image_b64.split(',')[1])
490
- reconstructed_image_pil = Image.open(io.BytesIO(image_bytes))
491
- original_w, original_h = reconstructed_image_pil.size
492
-
493
- input_tensor = preprocess_image(image_bytes, model_data['input_size'])
494
- outputs = model_data['session'].run(None, {model_data['input_name']: input_tensor})[0]
495
-
496
- predictions = np.squeeze(outputs).T
497
- confident_preds = predictions[predictions[:, 4] > model_data['confidence']]
498
-
499
- if len(confident_preds) == 0:
500
- return {
501
- 'status': 'not_found',
502
- 'message': 'Tidak ada objek terdeteksi',
503
- 'processing_time': (datetime.now() - start_time).total_seconds()
504
- }
505
-
506
- best_detection = confident_preds[np.argmax(confident_preds[:, 4])]
507
- box_model = best_detection[:4]
508
-
509
- scale = min(model_data['input_size'] / original_w, model_data['input_size'] / original_h)
510
- pad_x = (model_data['input_size'] - original_w * scale) / 2
511
- pad_y = (model_data['input_size'] - original_h * scale) / 2
512
-
513
- x_center_orig = (box_model[0] - pad_x) / scale
514
- y_center_orig = (box_model[1] - pad_y) / scale
515
-
516
- button_to_click = get_button_index(x_center_orig, y_center_orig, original_w, original_h)
517
-
518
- return {
519
- 'status': 'success',
520
- 'button_index': button_to_click,
521
- 'confidence': float(best_detection[4]),
522
- 'processing_time': (datetime.now() - start_time).total_seconds()
523
- }
524
-
525
- except Exception as e:
526
- logger.error(f"Error in handle_upright_challenge: {e}")
527
- return {
528
- 'status': 'error',
529
- 'message': str(e),
530
- 'processing_time': (datetime.now() - start_time).total_seconds()
531
- }
532
-
533
- # =================================================================
534
- # FASTAPI APPLICATION
535
- # =================================================================
536
-
537
- app = FastAPI(
538
- title="🧩 FunCaptcha Solver API",
539
- description="High-performance FunCaptcha solver dengan fuzzy matching untuk Hugging Face Spaces",
540
- version="1.0.0",
541
- docs_url="/docs",
542
- redoc_url="/redoc"
543
- )
544
-
545
- # CORS middleware
546
- app.add_middleware(
547
- CORSMiddleware,
548
- allow_origins=["*"],
549
- allow_credentials=True,
550
- allow_methods=["*"],
551
- allow_headers=["*"],
552
- )
553
-
554
- @app.get("/")
555
- async def root():
556
- """Root endpoint dengan info API"""
557
- return {
558
- "service": "FunCaptcha Solver API",
559
- "version": "1.0.0",
560
- "status": "running",
561
- "endpoints": {
562
- "/solve": "POST - Solve FunCaptcha challenges",
563
- "/health": "GET - Health check",
564
- "/docs": "GET - API documentation"
565
- },
566
- "models_loaded": len(LOADED_MODELS),
567
- "cache_size": len(RESPONSE_CACHE)
568
- }
569
-
570
- @app.get("/health")
571
- async def health_check():
572
- """Health check endpoint"""
573
- warnings = []
574
- if not ONNX_AVAILABLE:
575
- warnings.append("ONNX Runtime not available")
576
- if not ML_BACKEND_AVAILABLE:
577
- warnings.append("No ML backend available - model inference disabled")
578
-
579
- backend_status = "none"
580
- if ONNX_AVAILABLE:
581
- backend_status = "onnxruntime"
582
- elif TORCH_AVAILABLE:
583
- backend_status = "pytorch"
584
- elif TF_AVAILABLE:
585
- backend_status = "tensorflow"
586
-
587
- return {
588
- "status": "healthy" if ML_BACKEND_AVAILABLE else "degraded",
589
- "service": "FunCaptcha Solver",
590
- "ml_backend": backend_status,
591
- "onnx_runtime_available": ONNX_AVAILABLE,
592
- "pytorch_available": TORCH_AVAILABLE,
593
- "tensorflow_available": TF_AVAILABLE,
594
- "models_loaded": len(LOADED_MODELS),
595
- "available_models": list(CONFIGS.keys()),
596
- "cache_entries": len(RESPONSE_CACHE),
597
- "warnings": warnings
598
- }
599
-
600
- @app.post("/solve", response_model=FunCaptchaResponse)
601
- async def solve_funcaptcha(
602
- request: FunCaptchaRequest,
603
- authenticated: bool = Depends(verify_api_key)
604
- ) -> FunCaptchaResponse:
605
- """
606
- 🧩 Solve FunCaptcha challenges
607
-
608
- Supports:
609
- - pick_the: Pick specific objects dari images
610
- - upright: Find correctly oriented objects
611
-
612
- Features:
613
- - Fuzzy label matching
614
- - Response caching
615
- - Multi-model support
616
- """
617
-
618
- # Generate cache key
619
- request_dict = request.dict()
620
- cache_key = get_cache_key(request_dict)
621
-
622
- # Check cache first
623
- cached_response = get_cached_response(cache_key)
624
- if cached_response:
625
- logger.info(f"Cache hit for challenge: {request.challenge_type}")
626
- return FunCaptchaResponse(**cached_response)
627
-
628
- # Process request
629
- if request.challenge_type == 'pick_the':
630
- if not request.target_label:
631
- raise HTTPException(status_code=400, detail="target_label required for pick_the challenges")
632
- result = await handle_pick_the_challenge(request_dict)
633
- elif request.challenge_type == 'upright':
634
- result = await handle_upright_challenge(request_dict)
635
- else:
636
- raise HTTPException(status_code=400, detail=f"Unsupported challenge type: {request.challenge_type}")
637
-
638
- # Cache response
639
- cache_response(cache_key, result)
640
-
641
- logger.info(f"Challenge solved: {request.challenge_type} -> {result['status']}")
642
-
643
- return FunCaptchaResponse(**result)
644
-
645
- # =================================================================
646
- # APPLICATION STARTUP
647
- # =================================================================
648
-
649
- @app.on_event("startup")
650
- async def startup_event():
651
- """Initialize aplikasi saat startup"""
652
- logger.info("πŸš€ Starting FunCaptcha Solver API...")
653
-
654
- # Verify API key ada
655
- try:
656
- api_key = get_api_key_from_secrets()
657
- logger.info("βœ… API key loaded successfully")
658
- except ValueError as e:
659
- logger.error(f"❌ API key error: {e}")
660
- raise e
661
-
662
- # Preload default model jika ada dan ML backend available
663
- if ML_BACKEND_AVAILABLE and os.path.exists('best.onnx') and os.path.exists('data.yaml'):
664
- logger.info("Preloading default model...")
665
- try:
666
- await ModelManager.get_model('default')
667
- logger.info("βœ… Default model preloaded successfully")
668
- except Exception as e:
669
- logger.warning(f"⚠️ Failed to preload default model: {e}")
670
- elif not ML_BACKEND_AVAILABLE:
671
- logger.warning("⚠️ No ML backend available - skipping model preload")
672
- else:
673
- logger.warning("⚠️ Model files (best.onnx, data.yaml) not found - upload them to enable solving")
674
-
675
- if ML_BACKEND_AVAILABLE:
676
- backend_name = "ONNX Runtime" if ONNX_AVAILABLE else "PyTorch" if TORCH_AVAILABLE else "TensorFlow"
677
- logger.info(f"βœ… FunCaptcha Solver API started successfully with {backend_name} backend")
678
- else:
679
- logger.warning("⚠️ FunCaptcha Solver API started with limited functionality (No ML backend available)")
680
-
681
- @app.on_event("shutdown")
682
- async def shutdown_event():
683
- """Cleanup saat shutdown"""
684
- logger.info("πŸ›‘ Shutting down FunCaptcha Solver API...")
685
-
686
- # Clear caches
687
- LOADED_MODELS.clear()
688
- RESPONSE_CACHE.clear()
689
-
690
- logger.info("βœ… Cleanup completed")
691
-
692
- # =================================================================
693
- # DEVELOPMENT SERVER
694
- # =================================================================
695
-
696
- if __name__ == "__main__":
697
- uvicorn.run(
698
- "app:app",
699
- host="0.0.0.0",
700
- port=7860,
701
- reload=False, # Disabled untuk production
702
- workers=1 # Single worker untuk HF Spaces
703
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ πŸš€ FunCaptcha Solver API - Hugging Face Spaces Deployment
3
+ Optimized for speed, memory efficiency, and scalability
4
+
5
+ Features:
6
+ - FastAPI async operations
7
+ - API key authentication via HF secrets
8
+ - Fuzzy label matching
9
+ - Memory-efficient model loading
10
+ - ONNX CPU optimization
11
+ - Request caching for performance
12
+ """
13
+
14
+ import os
15
+ import io
16
+ import base64
17
+ import hashlib
18
+ import asyncio
19
+ from datetime import datetime
20
+ from typing import Optional, Dict, Any, List
21
+ import logging
22
+
23
+ import cv2
24
+ import numpy as np
25
+ from PIL import Image
26
+ import yaml
27
+ import difflib
28
+
29
+ # Try to import ML backends dengan multiple fallbacks
30
+ ONNX_AVAILABLE = False
31
+ TORCH_AVAILABLE = False
32
+ TF_AVAILABLE = False
33
+ ort = None
34
+
35
+ # Try ONNX Runtime first
36
+ try:
37
+ import onnxruntime as ort
38
+ ONNX_AVAILABLE = True
39
+ print("βœ… ONNX Runtime imported successfully")
40
+ except ImportError as e:
41
+ print(f"❌ ONNX Runtime import failed: {e}")
42
+
43
+ # Try PyTorch as fallback
44
+ try:
45
+ import torch
46
+ TORCH_AVAILABLE = True
47
+ print("βœ… PyTorch imported as ONNX Runtime alternative")
48
+ except ImportError:
49
+ print("❌ PyTorch not available")
50
+
51
+ # Try TensorFlow as final fallback
52
+ try:
53
+ import tensorflow as tf
54
+ TF_AVAILABLE = True
55
+ print("βœ… TensorFlow imported as ONNX Runtime alternative")
56
+ except ImportError:
57
+ print("❌ TensorFlow not available")
58
+ print("⚠️ Running without ML backend - model inference will be disabled")
59
+
60
+ ML_BACKEND_AVAILABLE = ONNX_AVAILABLE or TORCH_AVAILABLE or TF_AVAILABLE
61
+
62
+ from fastapi import FastAPI, HTTPException, Depends, status
63
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
64
+ from fastapi.middleware.cors import CORSMiddleware
65
+ from pydantic import BaseModel, Field
66
+ import uvicorn
67
+
68
+ # Configure logging
69
+ logging.basicConfig(level=logging.INFO)
70
+ logger = logging.getLogger(__name__)
71
+
72
+ # =================================================================
73
+ # CONFIGURATION & MODELS
74
+ # =================================================================
75
+
76
+ class FunCaptchaRequest(BaseModel):
77
+ """Request model untuk FunCaptcha solving"""
78
+ challenge_type: str = Field(..., description="Type of challenge (pick_the, upright)")
79
+ image_b64: str = Field(..., description="Base64 encoded image")
80
+ target_label: Optional[str] = Field(None, description="Target label untuk pick_the challenges")
81
+
82
+ class FunCaptchaResponse(BaseModel):
83
+ """Response model untuk FunCaptcha solving"""
84
+ status: str = Field(..., description="Status: success, not_found, error")
85
+ box: Optional[List[float]] = Field(None, description="Bounding box coordinates [x, y, w, h]")
86
+ button_index: Optional[int] = Field(None, description="Button index untuk upright challenges")
87
+ confidence: Optional[float] = Field(None, description="Detection confidence")
88
+ message: Optional[str] = Field(None, description="Additional message")
89
+ processing_time: Optional[float] = Field(None, description="Processing time in seconds")
90
+
91
+ # =================================================================
92
+ # AUTHENTICATION
93
+ # =================================================================
94
+
95
+ security = HTTPBearer()
96
+
97
+ def get_api_key_from_secrets() -> str:
98
+ """Get API key dari Hugging Face Secrets"""
99
+ api_key = os.getenv("FUNCAPTCHA_API_KEY")
100
+ if not api_key:
101
+ logger.error("FUNCAPTCHA_API_KEY not found in environment variables")
102
+ raise ValueError("API key tidak ditemukan dalam HF Secrets")
103
+ return api_key
104
+
105
+ def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)) -> bool:
106
+ """Verify API key dari request header"""
107
+ expected_key = get_api_key_from_secrets()
108
+ if credentials.credentials != expected_key:
109
+ raise HTTPException(
110
+ status_code=status.HTTP_401_UNAUTHORIZED,
111
+ detail="Invalid API key",
112
+ headers={"WWW-Authenticate": "Bearer"}
113
+ )
114
+ return True
115
+
116
+ # =================================================================
117
+ # MODEL CONFIGURATION & MANAGEMENT
118
+ # =================================================================
119
+
120
+ CONFIGS = {
121
+ 'default': {
122
+ 'model_path': 'best.onnx',
123
+ 'yaml_path': 'data.yaml',
124
+ 'input_size': 640,
125
+ 'confidence_threshold': 0.4,
126
+ 'nms_threshold': 0.2
127
+ },
128
+ 'spiral_galaxy': {
129
+ 'model_path': 'bestspiral.onnx',
130
+ 'yaml_path': 'dataspiral.yaml',
131
+ 'input_size': 416,
132
+ 'confidence_threshold': 0.30,
133
+ 'nms_threshold': 0.45
134
+ },
135
+ 'upright': {
136
+ 'model_path': 'best_upright.onnx',
137
+ 'yaml_path': 'data_upright.yaml',
138
+ 'input_size': 640,
139
+ 'confidence_threshold': 0.45,
140
+ 'nms_threshold': 0.45
141
+ }
142
+ }
143
+
144
+ MODEL_ROUTING = [
145
+ (['spiral', 'galaxy'], 'spiral_galaxy')
146
+ ]
147
+
148
+ # Global cache untuk models dan responses
149
+ LOADED_MODELS: Dict[str, Dict[str, Any]] = {}
150
+ RESPONSE_CACHE: Dict[str, Dict[str, Any]] = {}
151
+ CACHE_MAX_SIZE = 100
152
+
153
+ class ModelManager:
154
+ """Manager untuk loading dan caching models"""
155
+
156
+ @staticmethod
157
+ async def get_model(config_key: str) -> Optional[Dict[str, Any]]:
158
+ """Load model dengan caching untuk efficiency"""
159
+ # Check if any ML backend is available
160
+ if not ML_BACKEND_AVAILABLE:
161
+ logger.error("❌ No ML backend available - cannot load models")
162
+ return None
163
+
164
+ if config_key not in LOADED_MODELS:
165
+ logger.info(f"Loading model: {config_key}")
166
+
167
+ try:
168
+ config = CONFIGS[config_key]
169
+
170
+ # Check if files exist
171
+ if not os.path.exists(config['model_path']):
172
+ logger.warning(f"Model file not found: {config['model_path']}")
173
+ return None
174
+
175
+ if not os.path.exists(config['yaml_path']):
176
+ logger.warning(f"YAML file not found: {config['yaml_path']}")
177
+ return None
178
+
179
+ # Load model dengan available backend
180
+ session = None
181
+
182
+ if ONNX_AVAILABLE:
183
+ # Load ONNX session dengan CPU optimization
184
+ providers = ['CPUExecutionProvider']
185
+ session_options = ort.SessionOptions()
186
+ session_options.intra_op_num_threads = 2 # Optimize untuk CPU
187
+ session_options.inter_op_num_threads = 2
188
+ session_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
189
+ session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
190
+
191
+ session = ort.InferenceSession(
192
+ config['model_path'],
193
+ providers=providers,
194
+ sess_options=session_options
195
+ )
196
+ else:
197
+ # For now, only ONNX Runtime is supported for model loading
198
+ # PyTorch/TensorFlow alternatives would need model conversion
199
+ logger.error("❌ ONNX models require ONNX Runtime - other backends not yet implemented")
200
+ return None
201
+
202
+ # Load class names
203
+ with open(config['yaml_path'], 'r', encoding='utf-8') as file:
204
+ class_names = yaml.safe_load(file)['names']
205
+
206
+ LOADED_MODELS[config_key] = {
207
+ 'session': session,
208
+ 'class_names': class_names,
209
+ 'input_name': session.get_inputs()[0].name,
210
+ 'input_size': config['input_size'],
211
+ 'confidence': config['confidence_threshold'],
212
+ 'nms': config.get('nms_threshold', 0.45)
213
+ }
214
+
215
+ logger.info(f"βœ… Model loaded successfully: {config_key}")
216
+
217
+ except Exception as e:
218
+ logger.error(f"❌ Error loading model {config_key}: {e}")
219
+ return None
220
+
221
+ return LOADED_MODELS[config_key]
222
+
223
+ # =================================================================
224
+ # IMAGE PROCESSING & UTILITIES
225
+ # =================================================================
226
+
227
+ def preprocess_image(image_bytes: bytes, input_size: int) -> np.ndarray:
228
+ """Preprocess image untuk ONNX inference dengan optimasi memory"""
229
+ image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
230
+ image_np = np.array(image)
231
+ h, w, _ = image_np.shape
232
+
233
+ scale = min(input_size / w, input_size / h)
234
+ new_w, new_h = int(w * scale), int(h * scale)
235
+
236
+ resized_image = cv2.resize(image_np, (new_w, new_h))
237
+ padded_image = np.full((input_size, input_size, 3), 114, dtype=np.uint8)
238
+
239
+ # Calculate padding
240
+ y_offset = (input_size - new_h) // 2
241
+ x_offset = (input_size - new_w) // 2
242
+
243
+ padded_image[y_offset:y_offset + new_h, x_offset:x_offset + new_w, :] = resized_image
244
+
245
+ # Convert untuk ONNX
246
+ input_tensor = padded_image.astype(np.float32) / 255.0
247
+ input_tensor = np.transpose(input_tensor, (2, 0, 1))
248
+ input_tensor = np.expand_dims(input_tensor, axis=0)
249
+
250
+ return input_tensor
251
+
252
+ def fuzzy_match_label(target_label: str, class_names: List[str], threshold: float = 0.6) -> Optional[str]:
253
+ """Fuzzy matching untuk label variations"""
254
+ target_normalized = target_label.lower().strip()
255
+
256
+ # Dictionary untuk common variations
257
+ label_variants = {
258
+ 'ice cream': ['ice cream', 'icecream', 'ice'],
259
+ 'hotdog': ['hot dog', 'hotdog', 'hot-dog'],
260
+ 'hot dog': ['hot dog', 'hotdog', 'hot-dog'],
261
+ 'sunglasses': ['sunglasses', 'sun glasses', 'sunglass'],
262
+ 'sun glasses': ['sunglasses', 'sun glasses', 'sunglass']
263
+ }
264
+
265
+ # 1. Exact match
266
+ if target_normalized in class_names:
267
+ return target_normalized
268
+
269
+ # 2. Check known variants
270
+ for main_label, variants in label_variants.items():
271
+ if target_normalized in variants and main_label in class_names:
272
+ return main_label
273
+
274
+ # 3. Fuzzy matching
275
+ best_matches = difflib.get_close_matches(
276
+ target_normalized,
277
+ [name.lower() for name in class_names],
278
+ n=3,
279
+ cutoff=threshold
280
+ )
281
+
282
+ if best_matches:
283
+ for match in best_matches:
284
+ for class_name in class_names:
285
+ if class_name.lower() == match:
286
+ return class_name
287
+
288
+ # 4. Partial matching
289
+ for class_name in class_names:
290
+ if target_normalized in class_name.lower() or class_name.lower() in target_normalized:
291
+ return class_name
292
+
293
+ return None
294
+
295
+ def get_config_key_for_label(target_label: str) -> str:
296
+ """Determine which model config to use"""
297
+ for keywords, config_key in MODEL_ROUTING:
298
+ if any(keyword in target_label for keyword in keywords):
299
+ return config_key
300
+ return 'default'
301
+
302
+ def get_button_index(x_center: float, y_center: float, img_width: int, img_height: int,
303
+ grid_cols: int = 3, grid_rows: int = 2) -> int:
304
+ """Calculate button index dari coordinates"""
305
+
306
+ # Calculate grid cell dimensions
307
+ cell_width = img_width / grid_cols
308
+ cell_height = img_height / grid_rows
309
+
310
+ # Calculate which cell the center point falls into
311
+ col = int(x_center // cell_width)
312
+ row = int(y_center // cell_height)
313
+
314
+ # Ensure col and row are within bounds
315
+ col = max(0, min(col, grid_cols - 1))
316
+ row = max(0, min(row, grid_rows - 1))
317
+
318
+ # Calculate button index (1-based)
319
+ button_index = row * grid_cols + col + 1
320
+
321
+ # Debug logging
322
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Input coordinates: ({x_center:.2f}, {y_center:.2f})")
323
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Image dimensions: {img_width}x{img_height}")
324
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Grid: {grid_cols}x{grid_rows}")
325
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Cell dimensions: {cell_width:.2f}x{cell_height:.2f}")
326
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Grid position: col={col}, row={row}")
327
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Calculated button index: {button_index}")
328
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Grid layout visualization:")
329
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: [1] [2] [3]")
330
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: [4] [5] [6]")
331
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: X ranges: [0-{cell_width:.1f}] [{cell_width:.1f}-{cell_width*2:.1f}] [{cell_width*2:.1f}-{img_width}]")
332
+ logger.info(f"πŸ” BUTTON INDEX DEBUG: Y ranges: [0-{cell_height:.1f}] [{cell_height:.1f}-{img_height}]")
333
+
334
+ return button_index
335
+
336
+ # =================================================================
337
+ # CACHING SYSTEM
338
+ # =================================================================
339
+
340
+ def get_cache_key(request_data: dict) -> str:
341
+ """Generate cache key dari request data"""
342
+ cache_string = f"{request_data.get('challenge_type')}_{request_data.get('target_label')}_{request_data.get('image_b64', '')[:100]}"
343
+ return hashlib.md5(cache_string.encode()).hexdigest()
344
+
345
+ def get_cached_response(cache_key: str) -> Optional[dict]:
346
+ """Get response dari cache jika ada"""
347
+ return RESPONSE_CACHE.get(cache_key)
348
+
349
+ def cache_response(cache_key: str, response: dict):
350
+ """Cache response dengan size limit"""
351
+ if len(RESPONSE_CACHE) >= CACHE_MAX_SIZE:
352
+ # Remove oldest entry
353
+ oldest_key = next(iter(RESPONSE_CACHE))
354
+ del RESPONSE_CACHE[oldest_key]
355
+
356
+ RESPONSE_CACHE[cache_key] = response
357
+
358
+ # =================================================================
359
+ # CHALLENGE HANDLERS
360
+ # =================================================================
361
+
362
+ async def handle_pick_the_challenge(data: dict) -> dict:
363
+ """Handle 'pick the' challenges dengan fuzzy matching"""
364
+ start_time = datetime.now()
365
+
366
+ target_label_original = data['target_label']
367
+ image_b64 = data['image_b64']
368
+ target_label = target_label_original
369
+
370
+ config_key = get_config_key_for_label(target_label)
371
+
372
+ if config_key == 'spiral_galaxy':
373
+ target_label = 'spiral'
374
+
375
+ model_data = await ModelManager.get_model(config_key)
376
+ if not model_data:
377
+ if not ML_BACKEND_AVAILABLE:
378
+ return {
379
+ 'status': 'error',
380
+ 'message': 'No ML backend available - model inference disabled',
381
+ 'processing_time': (datetime.now() - start_time).total_seconds()
382
+ }
383
+ return {
384
+ 'status': 'error',
385
+ 'message': f'Model {config_key} tidak ditemukan',
386
+ 'processing_time': (datetime.now() - start_time).total_seconds()
387
+ }
388
+
389
+ try:
390
+ # Decode image
391
+ image_bytes = base64.b64decode(image_b64.split(',')[1])
392
+
393
+ # Fuzzy matching untuk label
394
+ matched_label = fuzzy_match_label(target_label, model_data['class_names'])
395
+ if not matched_label:
396
+ return {
397
+ 'status': 'not_found',
398
+ 'message': f'Label "{target_label}" tidak ditemukan dalam model',
399
+ 'processing_time': (datetime.now() - start_time).total_seconds()
400
+ }
401
+
402
+ target_label = matched_label
403
+
404
+ # Preprocessing
405
+ input_tensor = preprocess_image(image_bytes, model_data['input_size'])
406
+
407
+ # Inference
408
+ outputs = model_data['session'].run(None, {model_data['input_name']: input_tensor})[0]
409
+ predictions = np.squeeze(outputs).T
410
+
411
+ # Process detections
412
+ boxes = []
413
+ confidences = []
414
+ class_ids = []
415
+
416
+ for pred in predictions:
417
+ class_scores = pred[4:]
418
+ class_id = np.argmax(class_scores)
419
+ max_confidence = class_scores[class_id]
420
+
421
+ if max_confidence > model_data['confidence']:
422
+ confidences.append(float(max_confidence))
423
+ class_ids.append(class_id)
424
+ box_model = pred[:4]
425
+ x_center, y_center, width, height = box_model
426
+ x1 = x_center - width / 2
427
+ y1 = y_center - height / 2
428
+ boxes.append([int(x1), int(y1), int(width), int(height)])
429
+
430
+ if not boxes:
431
+ return {
432
+ 'status': 'not_found',
433
+ 'processing_time': (datetime.now() - start_time).total_seconds()
434
+ }
435
+
436
+ # Non-Maximum Suppression
437
+ indices = cv2.dnn.NMSBoxes(
438
+ np.array(boxes),
439
+ np.array(confidences),
440
+ model_data['confidence'],
441
+ model_data['nms']
442
+ )
443
+
444
+ if len(indices) == 0:
445
+ return {
446
+ 'status': 'not_found',
447
+ 'processing_time': (datetime.now() - start_time).total_seconds()
448
+ }
449
+
450
+ # Find target
451
+ target_class_id = model_data['class_names'].index(target_label)
452
+ best_match_box = None
453
+ highest_score = 0
454
+
455
+ for i in indices.flatten():
456
+ if class_ids[i] == target_class_id:
457
+ current_score = confidences[i]
458
+ if current_score > highest_score:
459
+ highest_score = current_score
460
+ best_match_box = boxes[i]
461
+
462
+ if best_match_box is not None:
463
+ # Scale back to original coordinates
464
+ img = Image.open(io.BytesIO(image_bytes))
465
+ original_w, original_h = img.size
466
+ scale = min(model_data['input_size'] / original_w, model_data['input_size'] / original_h)
467
+ pad_x = (model_data['input_size'] - original_w * scale) / 2
468
+ pad_y = (model_data['input_size'] - original_h * scale) / 2
469
+
470
+ x_orig = (best_match_box[0] - pad_x) / scale
471
+ y_orig = (best_match_box[1] - pad_y) / scale
472
+ w_orig = best_match_box[2] / scale
473
+ h_orig = best_match_box[3] / scale
474
+
475
+ return {
476
+ 'status': 'success',
477
+ 'box': [x_orig, y_orig, w_orig, h_orig],
478
+ 'confidence': highest_score,
479
+ 'processing_time': (datetime.now() - start_time).total_seconds()
480
+ }
481
+
482
+ except Exception as e:
483
+ logger.error(f"Error in handle_pick_the_challenge: {e}")
484
+ return {
485
+ 'status': 'error',
486
+ 'message': str(e),
487
+ 'processing_time': (datetime.now() - start_time).total_seconds()
488
+ }
489
+
490
+ return {
491
+ 'status': 'not_found',
492
+ 'processing_time': (datetime.now() - start_time).total_seconds()
493
+ }
494
+
495
+ async def handle_upright_challenge(data: dict) -> dict:
496
+ """Handle 'upright' challenges"""
497
+ start_time = datetime.now()
498
+
499
+ try:
500
+ image_b64 = data['image_b64']
501
+ model_data = await ModelManager.get_model('upright')
502
+
503
+ if not model_data:
504
+ if not ML_BACKEND_AVAILABLE:
505
+ return {
506
+ 'status': 'error',
507
+ 'message': 'No ML backend available - model inference disabled',
508
+ 'processing_time': (datetime.now() - start_time).total_seconds()
509
+ }
510
+ return {
511
+ 'status': 'error',
512
+ 'message': 'Model upright tidak ditemukan',
513
+ 'processing_time': (datetime.now() - start_time).total_seconds()
514
+ }
515
+
516
+ image_bytes = base64.b64decode(image_b64.split(',')[1])
517
+ reconstructed_image_pil = Image.open(io.BytesIO(image_bytes))
518
+ original_w, original_h = reconstructed_image_pil.size
519
+
520
+ # Debug: Log image dimensions
521
+ logger.info(f"πŸ” UPRIGHT DEBUG: Original image dimensions: {original_w}x{original_h}")
522
+
523
+ input_tensor = preprocess_image(image_bytes, model_data['input_size'])
524
+ outputs = model_data['session'].run(None, {model_data['input_name']: input_tensor})[0]
525
+
526
+ predictions = np.squeeze(outputs).T
527
+ confident_preds = predictions[predictions[:, 4] > model_data['confidence']]
528
+
529
+ # Debug: Log predictions info
530
+ logger.info(f"πŸ” UPRIGHT DEBUG: Total predictions: {len(predictions)}, Confident predictions: {len(confident_preds)}")
531
+ logger.info(f"πŸ” UPRIGHT DEBUG: Confidence threshold: {model_data['confidence']}")
532
+
533
+ if len(confident_preds) == 0:
534
+ return {
535
+ 'status': 'not_found',
536
+ 'message': 'Tidak ada objek terdeteksi',
537
+ 'processing_time': (datetime.now() - start_time).total_seconds()
538
+ }
539
+
540
+ # Debug: Log all confident predictions
541
+ for i, pred in enumerate(confident_preds):
542
+ logger.info(f"πŸ” UPRIGHT DEBUG: Prediction {i+1}: x_center={pred[0]:.2f}, y_center={pred[1]:.2f}, width={pred[2]:.2f}, height={pred[3]:.2f}, confidence={pred[4]:.4f}")
543
+
544
+ best_detection = confident_preds[np.argmax(confident_preds[:, 4])]
545
+ box_model = best_detection[:4]
546
+
547
+ # Debug: Log model space coordinates
548
+ logger.info(f"πŸ” UPRIGHT DEBUG: Best detection (model space): x_center={box_model[0]:.2f}, y_center={box_model[1]:.2f}, width={box_model[2]:.2f}, height={box_model[3]:.2f}")
549
+
550
+ scale = min(model_data['input_size'] / original_w, model_data['input_size'] / original_h)
551
+ pad_x = (model_data['input_size'] - original_w * scale) / 2
552
+ pad_y = (model_data['input_size'] - original_h * scale) / 2
553
+
554
+ # Debug: Log scaling parameters
555
+ logger.info(f"πŸ” UPRIGHT DEBUG: Scaling parameters: scale={scale:.4f}, pad_x={pad_x:.2f}, pad_y={pad_y:.2f}")
556
+ logger.info(f"πŸ” UPRIGHT DEBUG: Model input size: {model_data['input_size']}")
557
+
558
+ x_center_orig = (box_model[0] - pad_x) / scale
559
+ y_center_orig = (box_model[1] - pad_y) / scale
560
+
561
+ # Debug: Log original space coordinates
562
+ logger.info(f"πŸ” UPRIGHT DEBUG: Original space coordinates: x_center={x_center_orig:.2f}, y_center={y_center_orig:.2f}")
563
+
564
+ # Debug: Log grid calculation details
565
+ grid_cols, grid_rows = 3, 2
566
+ col = int(x_center_orig // (original_w / grid_cols))
567
+ row = int(y_center_orig // (original_h / grid_rows))
568
+ logger.info(f"πŸ” UPRIGHT DEBUG: Grid calculation: grid_cols={grid_cols}, grid_rows={grid_rows}")
569
+ logger.info(f"πŸ” UPRIGHT DEBUG: Cell calculation: col={col}, row={row}")
570
+ logger.info(f"πŸ” UPRIGHT DEBUG: Grid cell dimensions: width={original_w/grid_cols:.2f}, height={original_h/grid_rows:.2f}")
571
+
572
+ button_to_click = get_button_index(x_center_orig, y_center_orig, original_w, original_h)
573
+
574
+ # Debug: Log final result
575
+ logger.info(f"πŸ” UPRIGHT DEBUG: Final button index: {button_to_click}")
576
+ logger.info(f"πŸ” UPRIGHT DEBUG: Button layout (3x2 grid): [1, 2, 3] [4, 5, 6]")
577
+
578
+ return {
579
+ 'status': 'success',
580
+ 'button_index': button_to_click,
581
+ 'confidence': float(best_detection[4]),
582
+ 'processing_time': (datetime.now() - start_time).total_seconds()
583
+ }
584
+
585
+ except Exception as e:
586
+ logger.error(f"Error in handle_upright_challenge: {e}")
587
+ return {
588
+ 'status': 'error',
589
+ 'message': str(e),
590
+ 'processing_time': (datetime.now() - start_time).total_seconds()
591
+ }
592
+
593
+ # =================================================================
594
+ # FASTAPI APPLICATION
595
+ # =================================================================
596
+
597
+ app = FastAPI(
598
+ title="🧩 FunCaptcha Solver API",
599
+ description="High-performance FunCaptcha solver dengan fuzzy matching untuk Hugging Face Spaces",
600
+ version="1.0.0",
601
+ docs_url="/docs",
602
+ redoc_url="/redoc"
603
+ )
604
+
605
+ # CORS middleware
606
+ app.add_middleware(
607
+ CORSMiddleware,
608
+ allow_origins=["*"],
609
+ allow_credentials=True,
610
+ allow_methods=["*"],
611
+ allow_headers=["*"],
612
+ )
613
+
614
+ @app.get("/")
615
+ async def root():
616
+ """Root endpoint dengan info API"""
617
+ return {
618
+ "service": "FunCaptcha Solver API",
619
+ "version": "1.0.0",
620
+ "status": "running",
621
+ "endpoints": {
622
+ "/solve": "POST - Solve FunCaptcha challenges",
623
+ "/health": "GET - Health check",
624
+ "/docs": "GET - API documentation"
625
+ },
626
+ "models_loaded": len(LOADED_MODELS),
627
+ "cache_size": len(RESPONSE_CACHE)
628
+ }
629
+
630
+ @app.get("/health")
631
+ async def health_check():
632
+ """Health check endpoint"""
633
+ warnings = []
634
+ if not ONNX_AVAILABLE:
635
+ warnings.append("ONNX Runtime not available")
636
+ if not ML_BACKEND_AVAILABLE:
637
+ warnings.append("No ML backend available - model inference disabled")
638
+
639
+ backend_status = "none"
640
+ if ONNX_AVAILABLE:
641
+ backend_status = "onnxruntime"
642
+ elif TORCH_AVAILABLE:
643
+ backend_status = "pytorch"
644
+ elif TF_AVAILABLE:
645
+ backend_status = "tensorflow"
646
+
647
+ return {
648
+ "status": "healthy" if ML_BACKEND_AVAILABLE else "degraded",
649
+ "service": "FunCaptcha Solver",
650
+ "ml_backend": backend_status,
651
+ "onnx_runtime_available": ONNX_AVAILABLE,
652
+ "pytorch_available": TORCH_AVAILABLE,
653
+ "tensorflow_available": TF_AVAILABLE,
654
+ "models_loaded": len(LOADED_MODELS),
655
+ "available_models": list(CONFIGS.keys()),
656
+ "cache_entries": len(RESPONSE_CACHE),
657
+ "warnings": warnings
658
+ }
659
+
660
+ @app.post("/solve", response_model=FunCaptchaResponse)
661
+ async def solve_funcaptcha(
662
+ request: FunCaptchaRequest,
663
+ authenticated: bool = Depends(verify_api_key)
664
+ ) -> FunCaptchaResponse:
665
+ """
666
+ 🧩 Solve FunCaptcha challenges
667
+
668
+ Supports:
669
+ - pick_the: Pick specific objects dari images
670
+ - upright: Find correctly oriented objects
671
+
672
+ Features:
673
+ - Fuzzy label matching
674
+ - Response caching
675
+ - Multi-model support
676
+ """
677
+
678
+ # Generate cache key
679
+ request_dict = request.dict()
680
+ cache_key = get_cache_key(request_dict)
681
+
682
+ # Check cache first
683
+ cached_response = get_cached_response(cache_key)
684
+ if cached_response:
685
+ logger.info(f"Cache hit for challenge: {request.challenge_type}")
686
+ return FunCaptchaResponse(**cached_response)
687
+
688
+ # Process request
689
+ if request.challenge_type == 'pick_the':
690
+ if not request.target_label:
691
+ raise HTTPException(status_code=400, detail="target_label required for pick_the challenges")
692
+ result = await handle_pick_the_challenge(request_dict)
693
+ elif request.challenge_type == 'upright':
694
+ result = await handle_upright_challenge(request_dict)
695
+ else:
696
+ raise HTTPException(status_code=400, detail=f"Unsupported challenge type: {request.challenge_type}")
697
+
698
+ # Cache response
699
+ cache_response(cache_key, result)
700
+
701
+ logger.info(f"Challenge solved: {request.challenge_type} -> {result['status']}")
702
+
703
+ return FunCaptchaResponse(**result)
704
+
705
+ # =================================================================
706
+ # APPLICATION STARTUP
707
+ # =================================================================
708
+
709
+ @app.on_event("startup")
710
+ async def startup_event():
711
+ """Initialize aplikasi saat startup"""
712
+ logger.info("πŸš€ Starting FunCaptcha Solver API...")
713
+
714
+ # Verify API key ada
715
+ try:
716
+ api_key = get_api_key_from_secrets()
717
+ logger.info("βœ… API key loaded successfully")
718
+ except ValueError as e:
719
+ logger.error(f"❌ API key error: {e}")
720
+ raise e
721
+
722
+ # Preload default model jika ada dan ML backend available
723
+ if ML_BACKEND_AVAILABLE and os.path.exists('best.onnx') and os.path.exists('data.yaml'):
724
+ logger.info("Preloading default model...")
725
+ try:
726
+ await ModelManager.get_model('default')
727
+ logger.info("βœ… Default model preloaded successfully")
728
+ except Exception as e:
729
+ logger.warning(f"⚠️ Failed to preload default model: {e}")
730
+ elif not ML_BACKEND_AVAILABLE:
731
+ logger.warning("⚠️ No ML backend available - skipping model preload")
732
+ else:
733
+ logger.warning("⚠️ Model files (best.onnx, data.yaml) not found - upload them to enable solving")
734
+
735
+ if ML_BACKEND_AVAILABLE:
736
+ backend_name = "ONNX Runtime" if ONNX_AVAILABLE else "PyTorch" if TORCH_AVAILABLE else "TensorFlow"
737
+ logger.info(f"βœ… FunCaptcha Solver API started successfully with {backend_name} backend")
738
+ else:
739
+ logger.warning("⚠️ FunCaptcha Solver API started with limited functionality (No ML backend available)")
740
+
741
+ @app.on_event("shutdown")
742
+ async def shutdown_event():
743
+ """Cleanup saat shutdown"""
744
+ logger.info("πŸ›‘ Shutting down FunCaptcha Solver API...")
745
+
746
+ # Clear caches
747
+ LOADED_MODELS.clear()
748
+ RESPONSE_CACHE.clear()
749
+
750
+ logger.info("βœ… Cleanup completed")
751
+
752
+ # =================================================================
753
+ # DEVELOPMENT SERVER
754
+ # =================================================================
755
+
756
+ if __name__ == "__main__":
757
+ uvicorn.run(
758
+ "app:app",
759
+ host="0.0.0.0",
760
+ port=7860,
761
+ reload=False, # Disabled untuk production
762
+ workers=1 # Single worker untuk HF Spaces
763
+ )