stevelohwc commited on
Commit
7560b1c
·
1 Parent(s): 4a39e88

hfspace: update backend api server only

Browse files
Files changed (1) hide show
  1. Code/Backend/api_server.py +1038 -0
Code/Backend/api_server.py ADDED
@@ -0,0 +1,1038 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI REST API server for Pokemon Card Authentication.
3
+
4
+ This server wraps the DL prediction pipeline (ResNet50 + EfficientNet-B7)
5
+ to provide a clean REST interface for the frontend application.
6
+ """
7
+
8
+ import hashlib
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from threading import Lock, Thread
13
+ from typing import Any, Dict, Optional
14
+ from urllib.parse import urlparse
15
+ from urllib.request import Request, urlopen
16
+
17
+ BASE_DIR = Path(__file__).resolve().parent
18
+
19
+
20
+ def _find_model_package_root() -> Path:
21
+ candidates = [
22
+ BASE_DIR.parent / "Model", # Code/Model in monorepo
23
+ BASE_DIR.parent.parent / "Code" / "Model", # Repo root /Code/Model
24
+ BASE_DIR, # If src/ is vendored into Code/Backend/
25
+ ]
26
+ for candidate in candidates:
27
+ if (candidate / "src" / "dl" / "prediction_pipeline.py").is_file():
28
+ return candidate
29
+ raise RuntimeError(
30
+ "Could not locate model source package root containing "
31
+ "'src/dl/prediction_pipeline.py'. For Railway, prefer deploying "
32
+ "with Root Directory set to the repository root so `Code/Model/` is "
33
+ "included; alternatively vendor `Code/Model/src` into `Code/Backend/src`."
34
+ )
35
+
36
+
37
+ def _find_models_dir(model_package_root: Path) -> Path:
38
+ candidates = [
39
+ model_package_root / "data" / "models", # Monorepo Model directory
40
+ BASE_DIR / "data" / "models", # Vendored into Backend for deployment
41
+ ]
42
+ for candidate in candidates:
43
+ if candidate.is_dir():
44
+ return candidate
45
+ raise RuntimeError(
46
+ "Could not locate trained models directory. Ensure "
47
+ "model files are present in the deploy build context, or vendor "
48
+ "them into `Code/Backend/data/models`. Looked for: "
49
+ f"{candidates}"
50
+ )
51
+
52
+
53
+ def _discover_local_checkpoint(dl_models_dir: Path) -> Optional[Path]:
54
+ """
55
+ Discover a local checkpoint in preferred order.
56
+
57
+ Priority:
58
+ 1) *_best.pth
59
+ 2) *_final.pth
60
+ 3) any *.pth
61
+ """
62
+ if not dl_models_dir.exists():
63
+ return None
64
+
65
+ for pattern in ("*_best.pth", "*_final.pth", "*.pth"):
66
+ candidates = sorted(dl_models_dir.glob(pattern))
67
+ if candidates:
68
+ return candidates[-1]
69
+ return None
70
+
71
+
72
+ def _compute_sha256(file_path: Path) -> str:
73
+ """Compute SHA256 hash for file integrity checks."""
74
+ sha256 = hashlib.sha256()
75
+ with open(file_path, "rb") as f:
76
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
77
+ sha256.update(chunk)
78
+ return sha256.hexdigest()
79
+
80
+
81
+ def _resolve_model_filename(download_url: str, filename_override: Optional[str]) -> str:
82
+ """Resolve destination filename from override or URL path."""
83
+ if filename_override:
84
+ candidate = Path(filename_override).name
85
+ if candidate:
86
+ return candidate
87
+
88
+ candidate = Path(urlparse(download_url).path).name
89
+ if candidate:
90
+ return candidate
91
+
92
+ return "downloaded_model_best.pth"
93
+
94
+
95
+ def _download_file(download_url: str, destination: Path, bearer_token: Optional[str] = None, timeout_seconds: int = 120) -> None:
96
+ """Download file from URL to destination path."""
97
+ headers = {}
98
+ if bearer_token:
99
+ headers["Authorization"] = f"Bearer {bearer_token}"
100
+
101
+ destination.parent.mkdir(parents=True, exist_ok=True)
102
+ tmp_destination = destination.with_suffix(destination.suffix + ".tmp")
103
+
104
+ request = Request(download_url, headers=headers)
105
+ with urlopen(request, timeout=timeout_seconds) as response, open(tmp_destination, "wb") as out_file:
106
+ while True:
107
+ chunk = response.read(1024 * 1024)
108
+ if not chunk:
109
+ break
110
+ out_file.write(chunk)
111
+
112
+ tmp_destination.replace(destination)
113
+
114
+
115
+ def _download_checkpoint_from_env(dl_models_dir: Path) -> Optional[Path]:
116
+ """
117
+ Download checkpoint when DL_MODEL_URL is configured.
118
+
119
+ Optional env vars:
120
+ - DL_MODEL_FILENAME: override downloaded filename
121
+ - DL_MODEL_SHA256: expected checksum (lowercase hex)
122
+ - DL_MODEL_BEARER_TOKEN: bearer token for private URLs
123
+ """
124
+ download_url = os.getenv("DL_MODEL_URL", "").strip()
125
+ if not download_url:
126
+ return None
127
+
128
+ filename_override = os.getenv("DL_MODEL_FILENAME", "").strip() or None
129
+ expected_sha256 = os.getenv("DL_MODEL_SHA256", "").strip().lower() or None
130
+ bearer_token = os.getenv("DL_MODEL_BEARER_TOKEN", "").strip() or None
131
+
132
+ filename = _resolve_model_filename(download_url, filename_override)
133
+ destination = dl_models_dir / filename
134
+
135
+ if destination.exists() and expected_sha256:
136
+ existing_hash = _compute_sha256(destination).lower()
137
+ if existing_hash != expected_sha256:
138
+ print(f"⚠️ Existing checkpoint hash mismatch, re-downloading: {destination.name}")
139
+ destination.unlink()
140
+
141
+ if not destination.exists():
142
+ print(f"Downloading DL checkpoint from DL_MODEL_URL to {destination}")
143
+ _download_file(download_url, destination, bearer_token=bearer_token)
144
+
145
+ if expected_sha256:
146
+ actual_sha256 = _compute_sha256(destination).lower()
147
+ if actual_sha256 != expected_sha256:
148
+ try:
149
+ destination.unlink()
150
+ except OSError:
151
+ pass
152
+ raise RuntimeError(
153
+ f"Downloaded checkpoint hash mismatch for {destination.name}. "
154
+ f"Expected {expected_sha256}, got {actual_sha256}"
155
+ )
156
+
157
+ return destination
158
+
159
+
160
+ def _should_load_model_on_startup() -> bool:
161
+ """
162
+ Decide whether to eagerly load the DL model during startup.
163
+
164
+ Env var:
165
+ - DL_LOAD_ON_STARTUP=true|false (default: true)
166
+ """
167
+ raw = os.getenv("DL_LOAD_ON_STARTUP", "").strip().lower()
168
+ if raw in ("", "1", "true", "yes", "on"):
169
+ return True
170
+ if raw in ("0", "false", "no", "off"):
171
+ return False
172
+
173
+ print(f"⚠️ Invalid DL_LOAD_ON_STARTUP value '{raw}', defaulting to eager loading.")
174
+ return True
175
+
176
+
177
+ MODEL_PACKAGE_ROOT = _find_model_package_root()
178
+ MODELS_DIR = _find_models_dir(MODEL_PACKAGE_ROOT)
179
+
180
+ # Add model package root to path for importing `src.*` modules
181
+ sys.path.insert(0, str(MODEL_PACKAGE_ROOT))
182
+
183
+ from fastapi import FastAPI, HTTPException
184
+ from fastapi.middleware.cors import CORSMiddleware
185
+ from pydantic import BaseModel, Field
186
+ import numpy as np
187
+ import cv2
188
+ import base64
189
+ import json
190
+ import time
191
+
192
+ from src.dl.prediction_pipeline import create_dl_pipeline
193
+ from src.preprocessing.image_utils import check_image_quality
194
+ from src.preprocessing.card_detector import detect_card_boundary_strict
195
+
196
+ # Import validators
197
+ import sys
198
+ backend_src_path = str(BASE_DIR / "src")
199
+ if backend_src_path not in sys.path:
200
+ sys.path.insert(0, backend_src_path)
201
+
202
+ from validators.feature_based_validator import FeatureBasedValidator
203
+
204
+ # Initialize FastAPI app
205
+ app = FastAPI(
206
+ title="Pokemon Card Authentication API",
207
+ description="AI-powered Pokemon card authentication using ResNet50 + EfficientNet-B7",
208
+ version="2.0.0"
209
+ )
210
+
211
+ # CORS middleware for frontend connectivity
212
+ app.add_middleware(
213
+ CORSMiddleware,
214
+ allow_origins=[
215
+ "http://localhost:3000",
216
+ "http://127.0.0.1:3000",
217
+ "https://pokemonauthenticator.com",
218
+ "https://www.pokemonauthenticator.com",
219
+ ],
220
+ allow_origin_regex=r"^https://.*\.(vercel\.app|vercel\.com)$",
221
+ allow_credentials=True,
222
+ allow_methods=["*"],
223
+ allow_headers=["*"],
224
+ )
225
+
226
+ # Global DL pipeline instance
227
+ dl_pipeline = None
228
+ model_load_error = None
229
+ model_version_info = None # DL model version metadata
230
+ model_filename = None # DL model filename
231
+ model_registry = None # Cached model registry metadata
232
+ model_load_lock = Lock()
233
+ model_load_mode = "eager" # "eager" (default) or "lazy"
234
+ model_loading = False # True while a model load is in progress
235
+
236
+ # Global validators (validation layers kept from earlier pipeline revisions)
237
+ feature_validator = None
238
+
239
+
240
+
241
+ def _mean_saturation_in_region(image: np.ndarray, corners: np.ndarray, max_dim: int = 512) -> float:
242
+ """
243
+ Estimate mean HSV saturation inside the polygon defined by corners.
244
+
245
+ Used as a lightweight guard to reject non-card images that may still form a
246
+ rectangle (e.g., PDF icons, documents).
247
+ """
248
+ try:
249
+ height, width = image.shape[:2]
250
+ if height <= 0 or width <= 0:
251
+ return 0.0
252
+
253
+ scale = 1.0
254
+ work_image = image
255
+ if max(height, width) > max_dim:
256
+ scale = max_dim / float(max(height, width))
257
+ new_w = max(1, int(width * scale))
258
+ new_h = max(1, int(height * scale))
259
+ work_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
260
+
261
+ scaled_corners = (corners * scale).astype(np.int32)
262
+
263
+ hsv = cv2.cvtColor(work_image, cv2.COLOR_BGR2HSV)
264
+ mask = np.zeros(work_image.shape[:2], dtype=np.uint8)
265
+ cv2.fillPoly(mask, [scaled_corners], 255)
266
+
267
+ if cv2.countNonZero(mask) == 0:
268
+ return 0.0
269
+
270
+ saturation = hsv[:, :, 1]
271
+ return float(saturation[mask == 255].mean())
272
+ except Exception:
273
+ return 0.0
274
+
275
+
276
+ def _load_version_registry(registry_path: Path) -> Optional[dict]:
277
+ """Load version registry if it exists."""
278
+ if not registry_path.exists():
279
+ print(f"⚠️ Version registry not found: {registry_path}")
280
+ return None
281
+
282
+ try:
283
+ with open(registry_path, 'r') as f:
284
+ registry = json.load(f)
285
+ print(f"✅ Loaded version registry (schema v{registry.get('schema_version')})")
286
+ return registry
287
+ except Exception as e:
288
+ print(f"⚠️ Failed to load version registry: {e}")
289
+ return None
290
+
291
+
292
+ def _get_model_version_info(registry: Optional[dict], model_filename: str) -> Optional[Dict[str, Any]]:
293
+ """Extract version info for a specific model from registry."""
294
+ if registry is None:
295
+ return None
296
+
297
+ try:
298
+ # Prefer exact filename match across all model types.
299
+ for model_entries in registry.get('models', {}).values():
300
+ for model_entry in model_entries:
301
+ if model_entry.get('filename') == model_filename:
302
+ return model_entry
303
+
304
+ # Fallback: Extract version token (YYYYMMDD_HHMMSS) from filename.
305
+ stem = Path(model_filename).stem
306
+ import re
307
+ match = re.search(r"(\d{8}_\d{6})", stem)
308
+ if not match:
309
+ return None
310
+ version = match.group(1)
311
+
312
+ for model_entries in registry.get('models', {}).values():
313
+ for model_entry in model_entries:
314
+ if model_entry.get('version') == version:
315
+ return model_entry
316
+
317
+ except Exception as e:
318
+ print(f"⚠️ Failed to extract version info: {e}")
319
+ return None
320
+
321
+ return None
322
+
323
+
324
+ def _initialize_feature_validator() -> None:
325
+ """Initialize pre-DL validation layers."""
326
+ global feature_validator
327
+
328
+ if feature_validator is not None:
329
+ return
330
+
331
+ print("\n" + "=" * 80)
332
+ print("Initializing validators...")
333
+ print("=" * 80)
334
+
335
+ try:
336
+ feature_validator = FeatureBasedValidator(confidence_threshold=0.75)
337
+ print("✅ Pokemon card validators loaded (color-based back validation)")
338
+ except Exception as e:
339
+ print(f"⚠️ Failed to load validators: {e}")
340
+ import traceback
341
+ traceback.print_exc()
342
+ feature_validator = None
343
+
344
+
345
+ def _load_dl_pipeline_on_demand():
346
+ """Lazy-load DL pipeline on first authenticate request."""
347
+ global dl_pipeline, model_load_error, model_version_info, model_filename, model_registry, model_loading
348
+
349
+ if dl_pipeline is not None:
350
+ return dl_pipeline
351
+
352
+ # Avoid repeated expensive retries when the model has already failed to load.
353
+ if model_load_error:
354
+ return None
355
+
356
+ with model_load_lock:
357
+ if dl_pipeline is not None:
358
+ return dl_pipeline
359
+ if model_load_error:
360
+ return None
361
+
362
+ model_loading = True
363
+ try:
364
+ print("\n" + "=" * 80)
365
+ print("Loading DL model...")
366
+ print("=" * 80)
367
+
368
+ if model_registry is None:
369
+ registry_path = MODELS_DIR / "version_registry.json"
370
+ model_registry = _load_version_registry(registry_path)
371
+
372
+ dl_models_dir = MODELS_DIR / "dl"
373
+ dl_model_path = None
374
+ download_error = None
375
+
376
+ # Find local DL checkpoint first
377
+ discovered_checkpoint = _discover_local_checkpoint(dl_models_dir)
378
+ if discovered_checkpoint is not None:
379
+ dl_model_path = str(discovered_checkpoint)
380
+ model_filename = discovered_checkpoint.name
381
+ print(f"Loading local DL model: {model_filename}")
382
+ else:
383
+ try:
384
+ downloaded_checkpoint = _download_checkpoint_from_env(dl_models_dir)
385
+ if downloaded_checkpoint is not None:
386
+ dl_model_path = str(downloaded_checkpoint)
387
+ model_filename = downloaded_checkpoint.name
388
+ print(f"Loading downloaded DL model: {model_filename}")
389
+ except Exception as e:
390
+ download_error = str(e)
391
+ print(f"⚠️ DL checkpoint download failed: {download_error}")
392
+
393
+ if dl_model_path:
394
+ try:
395
+ dl_pipeline = create_dl_pipeline(
396
+ model_path=dl_model_path,
397
+ preprocessing_config={"target_size": 256},
398
+ )
399
+ print(f"✅ DL pipeline loaded: {model_filename}")
400
+
401
+ # Extract version info
402
+ version_entry = _get_model_version_info(model_registry, model_filename)
403
+ if version_entry:
404
+ model_version_info = version_entry
405
+ print(f"✅ DL Model version: {version_entry.get('version')} ({version_entry.get('status')})")
406
+ except Exception as e:
407
+ import traceback
408
+ print(f"⚠️ DL model failed to load: {e}")
409
+ traceback.print_exc()
410
+ dl_pipeline = None
411
+
412
+ if dl_pipeline is None:
413
+ error_msg_parts = [f"No DL model found in {MODELS_DIR / 'dl'}."]
414
+ if download_error:
415
+ error_msg_parts.append(f"DL_MODEL_URL bootstrap failed: {download_error}.")
416
+ error_msg_parts.append(
417
+ "Provide a checkpoint in that directory, or set DL_MODEL_URL "
418
+ "(optional: DL_MODEL_FILENAME, DL_MODEL_SHA256, DL_MODEL_BEARER_TOKEN)."
419
+ )
420
+ error_msg_parts.append("Train locally with: cd ../Model && python -m src.dl.train_dl")
421
+ model_load_error = " ".join(error_msg_parts)
422
+ print(f"❌ {model_load_error}")
423
+ return None
424
+
425
+ model_load_error = None
426
+ print("=" * 80)
427
+ return dl_pipeline
428
+ except Exception as e:
429
+ import traceback
430
+ print(f"⚠️ Unexpected DL model load failure: {e}")
431
+ traceback.print_exc()
432
+ dl_pipeline = None
433
+ model_load_error = f"Unexpected DL model load failure: {e}"
434
+ return None
435
+ finally:
436
+ model_loading = False
437
+
438
+
439
+ def _start_background_model_load_if_needed() -> bool:
440
+ """
441
+ Trigger model loading in a daemon thread.
442
+
443
+ Returns:
444
+ True if a new background load was started, False otherwise.
445
+ """
446
+ global model_loading, model_load_error
447
+
448
+ with model_load_lock:
449
+ if dl_pipeline is not None or model_load_error or model_loading:
450
+ return False
451
+ model_loading = True
452
+
453
+ def _run_loader():
454
+ global model_loading
455
+ try:
456
+ _load_dl_pipeline_on_demand()
457
+ finally:
458
+ model_loading = False
459
+
460
+ try:
461
+ Thread(target=_run_loader, daemon=True, name="dl-model-loader").start()
462
+ return True
463
+ except Exception as e:
464
+ model_loading = False
465
+ model_load_error = f"Failed to start background model load: {e}"
466
+ print(f"⚠️ {model_load_error}")
467
+ return False
468
+
469
+
470
+ @app.on_event("startup")
471
+ async def startup_event():
472
+ """Initialize lightweight components; defer DL model to first request."""
473
+ global model_registry, model_load_mode
474
+
475
+ load_on_startup = _should_load_model_on_startup()
476
+ model_load_mode = "eager" if load_on_startup else "lazy"
477
+
478
+ print("=" * 80)
479
+ print(f"Starting API (DL model load mode: {model_load_mode})...")
480
+ print(f"Models directory: {MODELS_DIR}")
481
+ print(f"Models directory exists: {MODELS_DIR.exists()}")
482
+
483
+ # Load version registry
484
+ registry_path = MODELS_DIR / "version_registry.json"
485
+ model_registry = _load_version_registry(registry_path)
486
+
487
+ # Initialize Pokemon card validators (validation layers unchanged)
488
+ _initialize_feature_validator()
489
+
490
+ if load_on_startup:
491
+ print("Eager mode enabled: loading DL model during startup.")
492
+ _load_dl_pipeline_on_demand()
493
+ else:
494
+ print("Lazy mode enabled: DL model initialization deferred to first /api/authenticate request.")
495
+ print("=" * 80)
496
+
497
+
498
+ # Pydantic models for request/response validation
499
+ class AuthenticateRequest(BaseModel):
500
+ """Request body for card authentication."""
501
+ front_image: str = Field(..., description="Base64 encoded front image")
502
+ back_image: str = Field(..., description="Base64 encoded back image")
503
+
504
+
505
+ class CardDetectRequest(BaseModel):
506
+ """Request body for card edge detection."""
507
+ image: str = Field(..., description="Base64 encoded image")
508
+
509
+
510
+ class CardDetectResponse(BaseModel):
511
+ """Response body for card edge detection."""
512
+ card_detected: bool = Field(..., description="True if card edges are detected")
513
+
514
+
515
+ class PredictionResult(BaseModel):
516
+ """Individual image prediction result."""
517
+ prediction: int = Field(..., description="-1=no_card, 0=counterfeit, 1=authentic")
518
+ label: str = Field(..., description="'authentic', 'counterfeit', or 'no_card'")
519
+ confidence: float = Field(..., ge=0, le=1, description="Confidence score")
520
+ probabilities: Dict[str, float] = Field(..., description="Class probabilities")
521
+ inference_time_ms: float = Field(..., description="Inference time in milliseconds")
522
+ component_scores: Optional[Dict[str, float]] = Field(None, description="Per-head DL scores")
523
+
524
+
525
+ class QualityCheckResult(BaseModel):
526
+ """Image quality check result."""
527
+ blur_score: float = Field(..., description="Laplacian variance (higher = sharper)")
528
+ brightness: float = Field(..., description="Mean pixel value (0-255)")
529
+ contrast: float = Field(..., description="Std deviation of pixels")
530
+ is_acceptable: bool = Field(..., description="Whether image passes quality checks")
531
+
532
+
533
+ class PokemonBackValidation(BaseModel):
534
+ """Pokemon back color validation result."""
535
+ passed: bool = Field(..., description="Whether back image passes Pokemon back validation")
536
+ confidence: float = Field(..., ge=0, le=1, description="Confidence score for validation")
537
+ reason: str = Field(..., description="Validation failure/success reason")
538
+
539
+
540
+ class ModelVersionInfo(BaseModel):
541
+ """Model version and training metadata."""
542
+ version: str = Field(..., description="Model version (timestamp)")
543
+ model_type: str = Field(..., description="Model type (dl_multihead)")
544
+ model_class: str = Field(default="", description="Python class name")
545
+ training_date: str = Field(default="", description="ISO timestamp of training")
546
+ status: str = Field(..., description="Deployment status (production, staging, training)")
547
+ accuracy: Optional[float] = Field(None, description="Test accuracy")
548
+ f1_score: Optional[float] = Field(None, description="Test F1 score")
549
+ roc_auc: Optional[float] = Field(None, description="Test ROC AUC")
550
+ dataset_size: Optional[int] = Field(None, description="Number of training samples")
551
+ n_features: Optional[Any] = Field(None, description="Number of features or 'end-to-end'")
552
+ pipeline_type: Optional[str] = Field(None, description="Pipeline type: 'dl'")
553
+ backbone: Optional[str] = Field(None, description="DL backbone architecture")
554
+
555
+
556
+ class RejectionReason(BaseModel):
557
+ """Detailed information about why a card was rejected as 'no_card'."""
558
+ category: str = Field(..., description="Rejection category: 'geometry', 'back_pattern', 'front_is_back', 'mismatch'")
559
+ message: str = Field(..., description="User-friendly error message")
560
+ details: Dict[str, Any] = Field(default_factory=dict, description="Technical details for debugging")
561
+
562
+
563
+ class AuthenticateResponse(BaseModel):
564
+ """Response body for card authentication."""
565
+ is_authentic: bool = Field(..., description="Final authentication result")
566
+ confidence: float = Field(..., ge=0, le=1, description="Overall confidence")
567
+ label: str = Field(..., description="'authentic', 'counterfeit', or 'no_card'")
568
+ probabilities: Dict[str, float] = Field(..., description="Average probabilities")
569
+ front_analysis: PredictionResult = Field(..., description="Front card analysis")
570
+ back_analysis: PredictionResult = Field(..., description="Back card analysis")
571
+ processing_time_ms: float = Field(..., description="Total processing time")
572
+ quality_checks: Dict[str, QualityCheckResult] = Field(..., description="Quality checks for both images")
573
+ pokemon_back_validation: Optional[PokemonBackValidation] = Field(None, description="Pokemon back validation result (if performed)")
574
+ model_version: Optional[ModelVersionInfo] = Field(None, description="DL model version information")
575
+ rejection_reason: Optional[RejectionReason] = Field(None, description="Detailed rejection reason (if label='no_card')")
576
+
577
+
578
+
579
+
580
+ @app.get("/")
581
+ async def root():
582
+ """Root endpoint."""
583
+ return {
584
+ "message": "Pokemon Card Authentication API",
585
+ "version": "2.0.0",
586
+ "status": "running",
587
+ "endpoints": {
588
+ "health": "/api/health",
589
+ "warmup": "/api/warmup",
590
+ "card_detect": "/api/card-detect",
591
+ "authenticate": "/api/authenticate",
592
+ "docs": "/docs"
593
+ }
594
+ }
595
+
596
+
597
+ @app.get("/api/health")
598
+ async def health_check():
599
+ """Health check endpoint to verify API and model status."""
600
+ if dl_pipeline is None:
601
+ response = {
602
+ "status": "degraded" if model_load_error else "ok",
603
+ "model_loaded": False,
604
+ "model_loading": model_loading,
605
+ "model_load_mode": model_load_mode,
606
+ "api_version": "2.0.0",
607
+ "error": model_load_error,
608
+ "models_dir": str(MODELS_DIR),
609
+ "models_dir_exists": MODELS_DIR.exists(),
610
+ }
611
+ if model_version_info:
612
+ response["model_version"] = ModelVersionInfo(**model_version_info).model_dump()
613
+ return response
614
+
615
+ response = {
616
+ "status": "ok",
617
+ "model_loaded": True,
618
+ "model_loading": model_loading,
619
+ "model_load_mode": model_load_mode,
620
+ "api_version": "2.0.0",
621
+ "model_name": model_filename or "dl_model",
622
+ }
623
+
624
+ # Add version info if available
625
+ if model_version_info:
626
+ response["model_version"] = ModelVersionInfo(**model_version_info).model_dump()
627
+
628
+ return response
629
+
630
+
631
+ @app.post("/api/warmup")
632
+ async def warmup_model():
633
+ """Trigger asynchronous DL model loading."""
634
+ if dl_pipeline is not None:
635
+ return {
636
+ "status": "ready",
637
+ "model_loaded": True,
638
+ "model_loading": False,
639
+ "model_load_mode": model_load_mode,
640
+ }
641
+
642
+ if model_load_error:
643
+ return {
644
+ "status": "error",
645
+ "model_loaded": False,
646
+ "model_loading": False,
647
+ "model_load_mode": model_load_mode,
648
+ "error": model_load_error,
649
+ }
650
+
651
+ started = _start_background_model_load_if_needed()
652
+ return {
653
+ "status": "warming" if (started or model_loading) else "pending",
654
+ "model_loaded": False,
655
+ "model_loading": True if (started or model_loading) else False,
656
+ "model_load_mode": model_load_mode,
657
+ }
658
+
659
+
660
+ @app.post("/api/card-detect", response_model=CardDetectResponse)
661
+ async def card_detect(request: CardDetectRequest):
662
+ """
663
+ Detect card edges in a single image.
664
+
665
+ Args:
666
+ request: Contains base64-encoded image
667
+
668
+ Returns:
669
+ Card detection result
670
+ """
671
+ img = decode_base64_image(request.image)
672
+ if img is None:
673
+ raise HTTPException(status_code=400, detail="Failed to decode image")
674
+
675
+ corners = detect_card_boundary_strict(
676
+ img,
677
+ min_area_ratio=0.001,
678
+ max_area_ratio=0.999,
679
+ aspect_ratio_range=(0.30, 1.0),
680
+ solidity_threshold=0.60,
681
+ fill_ratio_threshold=0.40,
682
+ )
683
+
684
+ return CardDetectResponse(card_detected=corners is not None)
685
+
686
+
687
+ @app.post("/api/authenticate", response_model=AuthenticateResponse)
688
+ async def authenticate_card(request: AuthenticateRequest):
689
+ """
690
+ Authenticate a Pokemon card using front and back images.
691
+
692
+ Args:
693
+ request: Contains base64-encoded front and back images
694
+
695
+ Returns:
696
+ Authentication result with confidence scores and quality checks
697
+
698
+ Raises:
699
+ HTTPException: If model not loaded or processing fails
700
+ """
701
+ if dl_pipeline is None and model_load_mode == "lazy":
702
+ if model_loading or _start_background_model_load_if_needed():
703
+ raise HTTPException(
704
+ status_code=503,
705
+ detail=(
706
+ "DL model warm-up in progress. Retry in 20-60 seconds. "
707
+ "You can poll /api/health (model_loaded/model_loading) or call /api/warmup."
708
+ ),
709
+ )
710
+
711
+ pipeline = _load_dl_pipeline_on_demand()
712
+ if pipeline is None:
713
+ raise HTTPException(
714
+ status_code=503,
715
+ detail=model_load_error or (
716
+ "No DL model loaded. Add checkpoint to Code/Model/data/models/dl "
717
+ "or set DL_MODEL_URL, then restart backend."
718
+ ),
719
+ )
720
+
721
+ print("Using DL pipeline for authentication")
722
+ start_time = time.time()
723
+
724
+ try:
725
+ # Decode base64 images
726
+ front_img = decode_base64_image(request.front_image)
727
+ back_img = decode_base64_image(request.back_image)
728
+
729
+ # Validate images
730
+ if front_img is None:
731
+ raise HTTPException(status_code=400, detail="Failed to decode front image")
732
+ if back_img is None:
733
+ raise HTTPException(status_code=400, detail="Failed to decode back image")
734
+
735
+ # Check image quality
736
+ front_quality = extract_quality_info(front_img)
737
+ back_quality = extract_quality_info(back_img)
738
+
739
+ def _no_card_result() -> Dict[str, Any]:
740
+ return {
741
+ 'prediction': -1,
742
+ 'label': 'no_card',
743
+ 'confidence': 0.0,
744
+ 'probabilities': {'authentic': 0.0, 'counterfeit': 0.0},
745
+ 'inference_time_ms': 0.0,
746
+ }
747
+
748
+ # Layer 1: Stricter geometric validation for Pokemon cards
749
+ # Strategy: Reject cards with incorrect aspect ratio or low saturation
750
+ # Pokemon cards have aspect ratio ~0.716 (63mm x 88mm)
751
+ front_corners = detect_card_boundary_strict(
752
+ front_img,
753
+ min_area_ratio=0.001, # Very low - card can be small in frame
754
+ max_area_ratio=0.999, # Almost entire image is OK for webcam shots
755
+ aspect_ratio_range=(0.65, 0.78), # Tighter - Pokemon cards are 0.716 ± 8%
756
+ solidity_threshold=0.60, # Lower solidity for cards with background
757
+ fill_ratio_threshold=0.40 # Lower fill ratio to be more permissive
758
+ )
759
+ back_corners = detect_card_boundary_strict(
760
+ back_img,
761
+ min_area_ratio=0.001,
762
+ max_area_ratio=0.999, # Almost entire image is OK for webcam shots
763
+ aspect_ratio_range=(0.65, 0.78), # Tighter - Pokemon cards are 0.716 ± 8%
764
+ solidity_threshold=0.60, # Lower solidity for cards with background
765
+ fill_ratio_threshold=0.40 # Lower fill ratio to be more permissive
766
+ )
767
+
768
+ # Saturation thresholds - key discriminator between Pokemon cards and other cards
769
+ # Pokemon cards typically have saturation 35-150 (colorful cards and blue backs)
770
+ # Poker cards and other trading cards may have lower saturation
771
+ front_sat_threshold = 35.0 # Increased from 20.0
772
+ back_sat_threshold = 35.0 # Increased from 20.0
773
+
774
+ front_saturation = _mean_saturation_in_region(front_img, front_corners) if front_corners is not None else 0.0
775
+ back_saturation = _mean_saturation_in_region(back_img, back_corners) if back_corners is not None else 0.0
776
+
777
+ front_passes = front_corners is not None and front_saturation >= front_sat_threshold
778
+ back_passes = back_corners is not None and back_saturation >= back_sat_threshold
779
+
780
+ # Three-tier validation strategy:
781
+ # 1. If BOTH pass → proceed to DL inference (normal case: card front + card back)
782
+ # 2. If BOTH fail → reject (no cards detected, e.g., human face + human face)
783
+ # 3. If ONLY ONE passes → reject (mismatched images, e.g., human face + card back)
784
+
785
+ if not front_passes and not back_passes:
786
+ # Case 2: Both images fail detection
787
+ processing_time_ms = (time.time() - start_time) * 1000
788
+ return AuthenticateResponse(
789
+ is_authentic=False,
790
+ confidence=0.0,
791
+ label='no_card',
792
+ probabilities={'authentic': 0.0, 'counterfeit': 0.0},
793
+ front_analysis=PredictionResult(**_no_card_result()),
794
+ back_analysis=PredictionResult(**_no_card_result()),
795
+ processing_time_ms=processing_time_ms,
796
+ quality_checks={
797
+ 'front': QualityCheckResult(**front_quality),
798
+ 'back': QualityCheckResult(**back_quality)
799
+ },
800
+ rejection_reason=RejectionReason(
801
+ category="geometry",
802
+ message="No Pokemon card detected in either image",
803
+ details={
804
+ "front_failure": "aspect ratio" if front_corners is None else "low saturation",
805
+ "back_failure": "aspect ratio" if back_corners is None else "low saturation",
806
+ "front_saturation": front_saturation,
807
+ "back_saturation": back_saturation
808
+ }
809
+ )
810
+ )
811
+
812
+ if front_passes != back_passes:
813
+ # Case 3: Only one image passes detection (mismatched)
814
+ # This catches: human face + card, or card + random object
815
+ processing_time_ms = (time.time() - start_time) * 1000
816
+ passing_side = "front" if front_passes else "back"
817
+ failing_side = "back" if front_passes else "front"
818
+ return AuthenticateResponse(
819
+ is_authentic=False,
820
+ confidence=0.0,
821
+ label='no_card',
822
+ probabilities={'authentic': 0.0, 'counterfeit': 0.0},
823
+ front_analysis=PredictionResult(**_no_card_result()),
824
+ back_analysis=PredictionResult(**_no_card_result()),
825
+ processing_time_ms=processing_time_ms,
826
+ quality_checks={
827
+ 'front': QualityCheckResult(**front_quality),
828
+ 'back': QualityCheckResult(**back_quality)
829
+ },
830
+ rejection_reason=RejectionReason(
831
+ category="mismatch",
832
+ message=f"Only {passing_side} image shows a valid card",
833
+ details={
834
+ "front_passed": front_passes,
835
+ "back_passed": back_passes,
836
+ "failing_side": failing_side
837
+ }
838
+ )
839
+ )
840
+
841
+ # Case 1: Both images pass Layer 1 - proceed to Layer 2 validation
842
+
843
+ # Layer 2: Pokemon back color validation
844
+ back_validation_passed = True
845
+ back_validation_reason = "Skipped"
846
+ back_validation_confidence = 0.0
847
+
848
+ # Use color-based validation (checks for distinctive blue Pokemon back pattern)
849
+ if feature_validator is not None:
850
+ is_pokemon_back, back_conf, back_reason = feature_validator.validate_pokemon_back_colors(back_img)
851
+ back_validation_passed = is_pokemon_back
852
+ back_validation_confidence = back_conf
853
+ back_validation_reason = back_reason
854
+ print(f"Back validation: passed={is_pokemon_back}, confidence={back_conf:.2%}")
855
+
856
+ if not back_validation_passed:
857
+ # Reject as no_card - not a Pokemon card back
858
+ processing_time_ms = (time.time() - start_time) * 1000
859
+ return AuthenticateResponse(
860
+ is_authentic=False,
861
+ confidence=0.0,
862
+ label='no_card',
863
+ probabilities={'authentic': 0.0, 'counterfeit': 0.0},
864
+ front_analysis=PredictionResult(**_no_card_result()),
865
+ back_analysis=PredictionResult(**_no_card_result()),
866
+ processing_time_ms=processing_time_ms,
867
+ quality_checks={
868
+ 'front': QualityCheckResult(**front_quality),
869
+ 'back': QualityCheckResult(**back_quality)
870
+ },
871
+ pokemon_back_validation=PokemonBackValidation(
872
+ passed=False,
873
+ confidence=back_validation_confidence,
874
+ reason=back_validation_reason
875
+ ),
876
+ rejection_reason=RejectionReason(
877
+ category="back_pattern",
878
+ message="Back image does not show a Pokemon card",
879
+ details={
880
+ "validation_confidence": back_validation_confidence,
881
+ "reason": back_validation_reason
882
+ }
883
+ )
884
+ )
885
+
886
+ # Layer 2.5: Validate that FRONT image is NOT a Pokemon card back
887
+ # This catches the case where user scans two card backs
888
+ if feature_validator is not None:
889
+ is_front_also_back, front_back_conf, front_back_reason = feature_validator.validate_pokemon_back_colors(front_img)
890
+ if is_front_also_back:
891
+ # Front image appears to be a Pokemon card back - reject
892
+ processing_time_ms = (time.time() - start_time) * 1000
893
+ print(f"Front image rejected: appears to be a card back (confidence={front_back_conf:.2%})")
894
+ return AuthenticateResponse(
895
+ is_authentic=False,
896
+ confidence=0.0,
897
+ label='no_card',
898
+ probabilities={'authentic': 0.0, 'counterfeit': 0.0},
899
+ front_analysis=PredictionResult(**_no_card_result()),
900
+ back_analysis=PredictionResult(**_no_card_result()),
901
+ processing_time_ms=processing_time_ms,
902
+ quality_checks={
903
+ 'front': QualityCheckResult(**front_quality),
904
+ 'back': QualityCheckResult(**back_quality)
905
+ },
906
+ pokemon_back_validation=PokemonBackValidation(
907
+ passed=False,
908
+ confidence=front_back_conf,
909
+ reason=f"Front image appears to be a card back: {front_back_reason}"
910
+ ),
911
+ rejection_reason=RejectionReason(
912
+ category="front_is_back",
913
+ message="Both images appear to be card backs",
914
+ details={
915
+ "front_confidence": front_back_conf,
916
+ "reason": front_back_reason
917
+ }
918
+ )
919
+ )
920
+
921
+ # Layer 3: DL authentication
922
+ front_result = pipeline.predict(front_img, is_back=False)
923
+ back_result = pipeline.predict(back_img, is_back=True)
924
+
925
+ # Combine results (average probabilities)
926
+ avg_authentic_prob = (
927
+ front_result['probabilities']['authentic'] +
928
+ back_result['probabilities']['authentic']
929
+ ) / 2
930
+ avg_counterfeit_prob = 1 - avg_authentic_prob
931
+
932
+ # Determine final label
933
+ final_label = 'authentic' if avg_authentic_prob >= 0.5 else 'counterfeit'
934
+ final_confidence = max(avg_authentic_prob, avg_counterfeit_prob)
935
+
936
+ # Calculate total processing time
937
+ processing_time_ms = (time.time() - start_time) * 1000
938
+
939
+ # Build response with optional model version
940
+ response_data = {
941
+ 'is_authentic': avg_authentic_prob >= 0.5,
942
+ 'confidence': final_confidence,
943
+ 'label': final_label,
944
+ 'probabilities': {
945
+ 'authentic': avg_authentic_prob,
946
+ 'counterfeit': avg_counterfeit_prob
947
+ },
948
+ 'front_analysis': PredictionResult(**front_result),
949
+ 'back_analysis': PredictionResult(**back_result),
950
+ 'processing_time_ms': processing_time_ms,
951
+ 'quality_checks': {
952
+ 'front': QualityCheckResult(**front_quality),
953
+ 'back': QualityCheckResult(**back_quality)
954
+ }
955
+ }
956
+
957
+ # Add model version info if available
958
+ if model_version_info:
959
+ response_data['model_version'] = ModelVersionInfo(**model_version_info)
960
+
961
+ return AuthenticateResponse(**response_data)
962
+
963
+ except HTTPException:
964
+ raise
965
+ except Exception as e:
966
+ raise HTTPException(
967
+ status_code=500,
968
+ detail=f"Authentication failed: {str(e)}"
969
+ )
970
+
971
+
972
+ def decode_base64_image(base64_str: str) -> Optional[np.ndarray]:
973
+ """
974
+ Decode base64 string to OpenCV image (BGR format).
975
+
976
+ Args:
977
+ base64_str: Base64 encoded image string (with or without data URI prefix)
978
+
979
+ Returns:
980
+ NumPy array in BGR format, or None if decoding fails
981
+ """
982
+ try:
983
+ # Remove data URI prefix if present (data:image/jpeg;base64,...)
984
+ if ',' in base64_str:
985
+ base64_str = base64_str.split(',')[1]
986
+
987
+ # Decode base64 to bytes
988
+ img_bytes = base64.b64decode(base64_str)
989
+
990
+ # Convert bytes to numpy array
991
+ nparr = np.frombuffer(img_bytes, np.uint8)
992
+
993
+ # Decode to OpenCV image
994
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
995
+
996
+ return img
997
+ except Exception as e:
998
+ print(f"Error decoding base64 image: {e}")
999
+ return None
1000
+
1001
+
1002
+ def extract_quality_info(img: np.ndarray) -> Dict[str, Any]:
1003
+ """
1004
+ Extract quality metrics from image.
1005
+
1006
+ Args:
1007
+ img: OpenCV image (BGR format)
1008
+
1009
+ Returns:
1010
+ Dictionary with quality metrics
1011
+ """
1012
+ try:
1013
+ quality = check_image_quality(img)
1014
+ return {
1015
+ 'blur_score': float(quality['blur_score']),
1016
+ 'brightness': float(quality['brightness']),
1017
+ 'contrast': float(quality['contrast']),
1018
+ 'is_acceptable': bool(quality['is_acceptable'])
1019
+ }
1020
+ except Exception as e:
1021
+ print(f"Error checking image quality: {e}")
1022
+ # Return default values on error
1023
+ return {
1024
+ 'blur_score': 0.0,
1025
+ 'brightness': 0.0,
1026
+ 'contrast': 0.0,
1027
+ 'is_acceptable': False
1028
+ }
1029
+
1030
+
1031
+ if __name__ == "__main__":
1032
+ import uvicorn
1033
+ uvicorn.run(
1034
+ app,
1035
+ host="0.0.0.0",
1036
+ port=8000,
1037
+ log_level="info"
1038
+ )