gpimentel commited on
Commit
d56d352
·
1 Parent(s): 441cb2a

feat: implement anomaly detection ensemble, feedback system, and upgraded model configuration

Browse files
README.md CHANGED
@@ -18,10 +18,10 @@ pinned: true
18
  </p>
19
 
20
  <p align="center">
21
- <a href="#"><img src="https://img.shields.io/badge/version-1.0.0-blueviolet" alt="Latest Version"/></a>
22
  <a href="#"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"/></a>
23
  <a href="#"><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python Version"/></a>
24
- <a href="#"><img src="https://img.shields.io/badge/next.js-14%2B-black" alt="Next.js Version"/></a>
25
  <a href="#"><img src="https://img.shields.io/badge/pytorch-2.0%2B-ee4c2c" alt="PyTorch Version"/></a>
26
  </p>
27
 
@@ -114,7 +114,9 @@ docker run -p 7860:7860 pokedex-web
114
  ## 🚀 Features
115
 
116
  - **Computer Vision Pipeline:** Automatically detects image sources (e.g., Anime versions, custom sites) and extracts normalized silhouettes via OpenCV (headless).
117
- - **High-Accuracy Inference:** Identifies 1,025 distinct Pokémon classes using a trained ResNet-18 architecture.
 
 
118
  - **Scientific Documentation:** Includes an embedded Research Paper route (`/research`) detailing the dataset preparation, method, and structural adaptations of the ResNet-18 model.
119
  - **Zero Disk Writes:** Inference is processed entirely in memory (`bytes` to `np.ndarray`), optimizing execution speed and security on free-tier cloud environments.
120
  - **Client-Side Image Resizing:** The frontend uses HTML5 Canvas to resize images before upload, saving bandwidth and backend CPU cycles.
 
18
  </p>
19
 
20
  <p align="center">
21
+ <a href="#"><img src="https://img.shields.io/badge/version-1.1.0-blueviolet" alt="Latest Version"/></a>
22
  <a href="#"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"/></a>
23
  <a href="#"><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python Version"/></a>
24
+ <a href="#"><img src="https://img.shields.io/badge/next.js-16%2B-black" alt="Next.js Version"/></a>
25
  <a href="#"><img src="https://img.shields.io/badge/pytorch-2.0%2B-ee4c2c" alt="PyTorch Version"/></a>
26
  </p>
27
 
 
114
  ## 🚀 Features
115
 
116
  - **Computer Vision Pipeline:** Automatically detects image sources (e.g., Anime versions, custom sites) and extracts normalized silhouettes via OpenCV (headless).
117
+ - **High-Accuracy Ensemble Inference:** Identifies 1,025 distinct Pokémon classes using a trained ResNet-18 architecture, combining predictions across independent runs.
118
+ - **Two-Stage Ensemble Open-Set Recognition:** Uses an ensemble of three models (seeds 3, 7, and 25). Features **Stage-One Soft-Voting Logit Thresholding ($\ge 6.5$)** to prevent overconfidence on noise inputs, and **Stage-Two Confidence Margin Thresholding ($\ge 0.30$)** to actively detect epistemic anomalies.
119
+ - **Active Learning Telemetry Flywheel:** Automatically logs predictions with low confidence margins or ensemble disagreements and securely uploads telemetry payloads to Hugging Face datasets, driving a continuous model optimization loop.
120
  - **Scientific Documentation:** Includes an embedded Research Paper route (`/research`) detailing the dataset preparation, method, and structural adaptations of the ResNet-18 model.
121
  - **Zero Disk Writes:** Inference is processed entirely in memory (`bytes` to `np.ndarray`), optimizing execution speed and security on free-tier cloud environments.
122
  - **Client-Side Image Resizing:** The frontend uses HTML5 Canvas to resize images before upload, saving bandwidth and backend CPU cycles.
backend/app.py CHANGED
@@ -12,6 +12,7 @@ from model import PokedexNet
12
  from pokemon_service import PokemonService
13
  from predict import predict_from_bytes
14
  from rate_limiter import get_rate_limiter
 
15
 
16
 
17
  @asynccontextmanager
@@ -19,26 +20,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
19
  """Lifecycle manager to load models and data into memory at startup."""
20
  print("Initializing Pokédex Web Backend...")
21
 
22
- # Instantiate services and hold them inside app.state container (prevents global mutability)
23
  app.state.pokemon_service = PokemonService(settings.DATABASE_PATH)
24
 
25
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
  app.state.device = device
27
  print(f"Using device: {device}")
28
 
29
- print(f"Loading model from {settings.MODEL_PATH}...")
30
- model = PokedexNet(num_classes=settings.NUM_CLASSES)
 
31
 
32
- if os.path.exists(settings.MODEL_PATH):
33
- state_dict = torch.load(settings.MODEL_PATH, map_location=device, weights_only=True)
34
- model.load_state_dict(state_dict)
35
- model.to(device)
36
- model.eval()
37
- app.state.model = model
38
- print("Model loaded successfully.")
 
 
 
 
 
 
 
39
  else:
40
- app.state.model = None
41
- print(f"WARNING: Model file not found at {settings.MODEL_PATH}. Inference will fail.")
42
 
43
  yield
44
  print("Shutting down Pokédex Web Backend...")
@@ -50,7 +57,6 @@ app = FastAPI(
50
  lifespan=lifespan
51
  )
52
 
53
- # Apply restrictive, environment-configurable CORS origins (loaded from .env)
54
  app.add_middleware(
55
  CORSMiddleware,
56
  allow_origins=settings.CORS_ORIGINS,
@@ -59,8 +65,9 @@ app.add_middleware(
59
  allow_headers=["*"],
60
  )
61
 
 
 
62
 
63
- # Reusable FastAPI dependency functions to inject services cleanly (enables perfect unit testing / mocking)
64
  def get_pokemon_service(request: Request) -> PokemonService:
65
  service = getattr(request.app.state, "pokemon_service", None)
66
  if not service:
@@ -68,11 +75,11 @@ def get_pokemon_service(request: Request) -> PokemonService:
68
  return service
69
 
70
 
71
- def get_model(request: Request) -> PokedexNet:
72
- model = getattr(request.app.state, "model", None)
73
- if not model:
74
- raise HTTPException(status_code=503, detail="Model is not loaded.")
75
- return model
76
 
77
 
78
  def get_device(request: Request) -> torch.device:
@@ -86,12 +93,13 @@ def get_device(request: Request) -> torch.device:
86
  async def health_check(request: Request):
87
  """Health check endpoint for monitoring."""
88
  service = get_pokemon_service(request)
89
- model_obj = getattr(request.app.state, "model", None)
90
  device_obj = getattr(request.app.state, "device", None)
91
 
92
  return {
93
  "status": "healthy",
94
- "model_loaded": model_obj is not None,
 
95
  "device": str(device_obj) if device_obj else "Not set",
96
  "version": settings.VERSION,
97
  "pokemon_count": len(service.pokemon_data)
@@ -124,7 +132,7 @@ async def predict(
124
  file: UploadFile = File(...),
125
  debug: bool = Query(False, description="Include processed silhouette in base64"),
126
  service: PokemonService = Depends(get_pokemon_service),
127
- model: PokedexNet = Depends(get_model),
128
  device: torch.device = Depends(get_device)
129
  ):
130
  """
@@ -149,19 +157,28 @@ async def predict(
149
 
150
  result = predict_from_bytes(
151
  file_bytes=file_bytes,
152
- model=model,
153
  device=device,
154
  id_to_name=id_to_name_map,
155
- include_debug=debug
 
 
156
  )
157
 
 
 
 
158
  return JSONResponse(content={
159
  "pokemon_id": result.pokemon_id,
160
  "name": result.name,
161
  "confidence": result.confidence,
162
  "detected_source": result.detected_source,
163
  "top_5": result.top_5,
164
- "debug_silhouette": result.debug_silhouette_b64
 
 
 
 
165
  })
166
 
167
  except ValueError as ve:
@@ -171,7 +188,6 @@ async def predict(
171
  raise HTTPException(status_code=500, detail="Internal server error during inference.")
172
 
173
 
174
- # SPA Fallback - MUST be the last route registered!
175
  static_dir = os.path.join(settings.BASE_DIR, settings.STATIC_DIR)
176
 
177
  if os.path.exists(static_dir) and os.listdir(static_dir):
 
12
  from pokemon_service import PokemonService
13
  from predict import predict_from_bytes
14
  from rate_limiter import get_rate_limiter
15
+ import feedback_router
16
 
17
 
18
  @asynccontextmanager
 
20
  """Lifecycle manager to load models and data into memory at startup."""
21
  print("Initializing Pokédex Web Backend...")
22
 
 
23
  app.state.pokemon_service = PokemonService(settings.DATABASE_PATH)
24
 
25
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
  app.state.device = device
27
  print(f"Using device: {device}")
28
 
29
+ print("Loading ensemble models...")
30
+ paths = [settings.MODEL_PATH_SEED_3, settings.MODEL_PATH_SEED_7, settings.MODEL_PATH_SEED_25]
31
+ app.state.models = []
32
 
33
+ for path in paths:
34
+ if os.path.exists(path):
35
+ print(f"Loading {path}...")
36
+ model = PokedexNet(num_classes=settings.NUM_CLASSES)
37
+ state_dict = torch.load(path, map_location=device, weights_only=True)
38
+ model.load_state_dict(state_dict)
39
+ model.to(device)
40
+ model.eval()
41
+ app.state.models.append(model)
42
+ else:
43
+ print(f"WARNING: Model file not found at {path}.")
44
+
45
+ if len(app.state.models) == 3:
46
+ print("All 3 ensemble models loaded successfully.")
47
  else:
48
+ print(f"WARNING: Only {len(app.state.models)}/3 models loaded. Inference may be degraded.")
 
49
 
50
  yield
51
  print("Shutting down Pokédex Web Backend...")
 
57
  lifespan=lifespan
58
  )
59
 
 
60
  app.add_middleware(
61
  CORSMiddleware,
62
  allow_origins=settings.CORS_ORIGINS,
 
65
  allow_headers=["*"],
66
  )
67
 
68
+ app.include_router(feedback_router.router)
69
+
70
 
 
71
  def get_pokemon_service(request: Request) -> PokemonService:
72
  service = getattr(request.app.state, "pokemon_service", None)
73
  if not service:
 
75
  return service
76
 
77
 
78
+ def get_ensemble_models(request: Request) -> list[PokedexNet]:
79
+ models = getattr(request.app.state, "models", None)
80
+ if not models or len(models) == 0:
81
+ raise HTTPException(status_code=503, detail="Models are not loaded.")
82
+ return models
83
 
84
 
85
  def get_device(request: Request) -> torch.device:
 
93
  async def health_check(request: Request):
94
  """Health check endpoint for monitoring."""
95
  service = get_pokemon_service(request)
96
+ models_obj = getattr(request.app.state, "models", None)
97
  device_obj = getattr(request.app.state, "device", None)
98
 
99
  return {
100
  "status": "healthy",
101
+ "models_loaded": models_obj is not None and len(models_obj) > 0,
102
+ "ensemble_count": len(models_obj) if models_obj else 0,
103
  "device": str(device_obj) if device_obj else "Not set",
104
  "version": settings.VERSION,
105
  "pokemon_count": len(service.pokemon_data)
 
132
  file: UploadFile = File(...),
133
  debug: bool = Query(False, description="Include processed silhouette in base64"),
134
  service: PokemonService = Depends(get_pokemon_service),
135
+ models: list[PokedexNet] = Depends(get_ensemble_models),
136
  device: torch.device = Depends(get_device)
137
  ):
138
  """
 
157
 
158
  result = predict_from_bytes(
159
  file_bytes=file_bytes,
160
+ models=models,
161
  device=device,
162
  id_to_name=id_to_name_map,
163
+ include_debug=debug,
164
+ margin_threshold=settings.ENSEMBLE_MARGIN_THRESHOLD,
165
+ logit_threshold=settings.ENSEMBLE_LOGIT_THRESHOLD
166
  )
167
 
168
+ if result.ensemble_metrics:
169
+ feedback_router.cache_prediction_metrics(result.image_hash, result.ensemble_metrics)
170
+
171
  return JSONResponse(content={
172
  "pokemon_id": result.pokemon_id,
173
  "name": result.name,
174
  "confidence": result.confidence,
175
  "detected_source": result.detected_source,
176
  "top_5": result.top_5,
177
+ "debug_silhouette": result.debug_silhouette_b64,
178
+ "status": result.status,
179
+ "votes": result.votes,
180
+ "models_in_doubt_count": result.models_in_doubt_count,
181
+ "image_hash": result.image_hash
182
  })
183
 
184
  except ValueError as ve:
 
188
  raise HTTPException(status_code=500, detail="Internal server error during inference.")
189
 
190
 
 
191
  static_dir = os.path.join(settings.BASE_DIR, settings.STATIC_DIR)
192
 
193
  if os.path.exists(static_dir) and os.listdir(static_dir):
backend/{pokedex_model.pth → best_model_multirun_seed_25.pth} RENAMED
File without changes
backend/best_model_multirun_seed_3.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e8e1b5bf2623e0de0dca671ce93ee42e8e04f4a84dc042394c941d88a316519
3
+ size 46867723
backend/best_model_multirun_seed_7.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f13bcf7b1fb96656daa000ab0240bfdfca10bde5b3be72889a1987cc3adefe90
3
+ size 46867723
backend/config.py CHANGED
@@ -1,7 +1,6 @@
1
  import os
2
  from pathlib import Path
3
 
4
- # Load environment variables from .env if it exists (pure Python, zero dependencies)
5
  BASE_DIR = Path(__file__).parent
6
  env_file = BASE_DIR / ".env"
7
  if env_file.exists():
@@ -11,7 +10,6 @@ if env_file.exists():
11
  line = line.strip()
12
  if line and not line.startswith("#") and "=" in line:
13
  key, val = line.split("=", 1)
14
- # Strip whitespace and potential surrounding quotes
15
  val_clean = val.strip().strip('"').strip("'")
16
  os.environ[key.strip()] = val_clean
17
  except Exception as e:
@@ -23,32 +21,39 @@ class Settings:
23
 
24
  BASE_DIR: Path = Path(__file__).parent
25
 
26
- MODEL_PATH: str = os.getenv("POKEDEX_MODEL_PATH", str(BASE_DIR / "pokedex_model.pth"))
 
 
 
 
27
  DATABASE_PATH: str = os.getenv("POKEDEX_DB_PATH", str(BASE_DIR / "poke_database.json"))
28
 
 
 
 
 
 
 
29
  NUM_CLASSES: int = int(os.getenv("POKEDEX_NUM_CLASSES", "1025"))
30
- MAX_UPLOAD_SIZE: int = int(os.getenv("POKEDEX_MAX_UPLOAD_SIZE", str(10 * 1024 * 1024))) # 10MB
31
 
32
  ALLOWED_TYPES: set[str] = {"image/png", "image/jpeg", "image/webp", "image/gif"}
33
 
34
  POKEAPI_BASE: str = os.getenv("POKEAPI_BASE", "https://pokeapi.co/api/v2")
35
  POKEAPI_CACHE: str = os.getenv("POKEAPI_CACHE", "/tmp/pokeapi_cache.json")
36
 
37
- # CORS Configurations from .env
38
  CORS_ORIGINS: list[str] = [
39
  origin.strip()
40
  for origin in os.getenv("POKEDEX_CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:7860").split(",")
41
  if origin.strip()
42
  ]
43
 
44
- # Rate Limiting Configurations from .env
45
  RATE_LIMIT_REQUESTS: int = int(os.getenv("POKEDEX_RATE_LIMIT_REQUESTS", "10"))
46
  RATE_LIMIT_WINDOW: int = int(os.getenv("POKEDEX_RATE_LIMIT_WINDOW", "60"))
47
 
48
- # PokéAPI Cache Configurations from .env (default: 30 days)
49
  POKEAPI_CACHE_TTL: int = int(os.getenv("POKEDEX_POKEAPI_CACHE_TTL", "2592000"))
50
 
51
- VERSION: str = "1.0.0"
52
  STATIC_DIR: str = "static"
53
 
54
 
 
1
  import os
2
  from pathlib import Path
3
 
 
4
  BASE_DIR = Path(__file__).parent
5
  env_file = BASE_DIR / ".env"
6
  if env_file.exists():
 
10
  line = line.strip()
11
  if line and not line.startswith("#") and "=" in line:
12
  key, val = line.split("=", 1)
 
13
  val_clean = val.strip().strip('"').strip("'")
14
  os.environ[key.strip()] = val_clean
15
  except Exception as e:
 
21
 
22
  BASE_DIR: Path = Path(__file__).parent
23
 
24
+ MODEL_PATH_SEED_3: str = os.getenv("POKEDEX_MODEL_PATH_SEED_3", str(BASE_DIR / "best_model_multirun_seed_3.pth"))
25
+ MODEL_PATH_SEED_7: str = os.getenv("POKEDEX_MODEL_PATH_SEED_7", str(BASE_DIR / "best_model_multirun_seed_7.pth"))
26
+ MODEL_PATH_SEED_25: str = os.getenv("POKEDEX_MODEL_PATH_SEED_25", str(BASE_DIR / "best_model_multirun_seed_25.pth"))
27
+ ENSEMBLE_MARGIN_THRESHOLD: float = float(os.getenv("POKEDEX_ENSEMBLE_MARGIN_THRESHOLD", "0.30"))
28
+ ENSEMBLE_LOGIT_THRESHOLD: float = float(os.getenv("POKEDEX_ENSEMBLE_LOGIT_THRESHOLD", "6.5"))
29
  DATABASE_PATH: str = os.getenv("POKEDEX_DB_PATH", str(BASE_DIR / "poke_database.json"))
30
 
31
+ PERSISTENT_STORAGE_PATH: str = os.getenv("PERSISTENT_STORAGE_PATH", str(BASE_DIR / "telemetry"))
32
+ PARTITION_INTERVAL: str = os.getenv("PARTITION_INTERVAL", "monthly")
33
+
34
+ HF_TOKEN: str | None = os.getenv("HF_TOKEN")
35
+ HF_DATASET_REPO: str | None = os.getenv("HF_DATASET_REPO")
36
+
37
  NUM_CLASSES: int = int(os.getenv("POKEDEX_NUM_CLASSES", "1025"))
38
+ MAX_UPLOAD_SIZE: int = int(os.getenv("POKEDEX_MAX_UPLOAD_SIZE", str(10 * 1024 * 1024)))
39
 
40
  ALLOWED_TYPES: set[str] = {"image/png", "image/jpeg", "image/webp", "image/gif"}
41
 
42
  POKEAPI_BASE: str = os.getenv("POKEAPI_BASE", "https://pokeapi.co/api/v2")
43
  POKEAPI_CACHE: str = os.getenv("POKEAPI_CACHE", "/tmp/pokeapi_cache.json")
44
 
 
45
  CORS_ORIGINS: list[str] = [
46
  origin.strip()
47
  for origin in os.getenv("POKEDEX_CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:7860").split(",")
48
  if origin.strip()
49
  ]
50
 
 
51
  RATE_LIMIT_REQUESTS: int = int(os.getenv("POKEDEX_RATE_LIMIT_REQUESTS", "10"))
52
  RATE_LIMIT_WINDOW: int = int(os.getenv("POKEDEX_RATE_LIMIT_WINDOW", "60"))
53
 
 
54
  POKEAPI_CACHE_TTL: int = int(os.getenv("POKEDEX_POKEAPI_CACHE_TTL", "2592000"))
55
 
56
+ VERSION: str = "1.1.0"
57
  STATIC_DIR: str = "static"
58
 
59
 
backend/feedback_router.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from datetime import datetime
4
+ from fastapi import APIRouter, BackgroundTasks, Request, HTTPException
5
+ from pydantic import BaseModel
6
+
7
+ from config import settings
8
+
9
+ router = APIRouter()
10
+
11
+ ip_request_counts = {}
12
+
13
+ prediction_metrics_cache = {}
14
+ prediction_metrics_order = []
15
+
16
+ def cache_prediction_metrics(image_hash: str, metrics: dict):
17
+ if image_hash not in prediction_metrics_cache:
18
+ prediction_metrics_order.append(image_hash)
19
+ prediction_metrics_cache[image_hash] = metrics
20
+
21
+ if len(prediction_metrics_order) > 1000:
22
+ oldest = prediction_metrics_order.pop(0)
23
+ prediction_metrics_cache.pop(oldest, None)
24
+
25
+
26
+ import hashlib
27
+
28
+ def calculate_md5(filepath: str) -> str:
29
+ md5_hash = hashlib.md5()
30
+ try:
31
+ with open(filepath, "rb") as f:
32
+ for byte_block in iter(lambda: f.read(4096), b""):
33
+ md5_hash.update(byte_block)
34
+ return md5_hash.hexdigest()
35
+ except FileNotFoundError:
36
+ return "file_not_found"
37
+
38
+ import os
39
+
40
+ def extract_model_name(filepath: str) -> str:
41
+ """Ex: /app/best_model_multirun_seed_3.pth -> best_model_multirun_seed_3 -> seed_3"""
42
+ basename = os.path.basename(filepath)
43
+ name_without_ext = os.path.splitext(basename)[0]
44
+ parts = name_without_ext.split("_")
45
+ if len(parts) >= 2:
46
+ return f"{parts[-2]}_{parts[-1]}"
47
+ return name_without_ext
48
+
49
+ MODELS_SIGNATURE = [
50
+ {"name": extract_model_name(settings.MODEL_PATH_SEED_3), "hash": calculate_md5(settings.MODEL_PATH_SEED_3)},
51
+ {"name": extract_model_name(settings.MODEL_PATH_SEED_7), "hash": calculate_md5(settings.MODEL_PATH_SEED_7)},
52
+ {"name": extract_model_name(settings.MODEL_PATH_SEED_25), "hash": calculate_md5(settings.MODEL_PATH_SEED_25)}
53
+ ]
54
+
55
+ class FeedbackPayload(BaseModel):
56
+ image_hash: str
57
+ predicted_id: int | None
58
+ is_correct: bool
59
+ user_correction_id: int | str | None = None
60
+ is_ood_screen: bool = False
61
+
62
+ system_version: str = settings.VERSION
63
+ ensemble_models: list[dict] = MODELS_SIGNATURE
64
+
65
+ def get_partitioned_filename():
66
+ """Generates the filename based on the partition interval."""
67
+ now = datetime.utcnow()
68
+
69
+ if settings.PARTITION_INTERVAL == "daily":
70
+ date_str = now.strftime("%Y-%m-%d")
71
+ else:
72
+ date_str = now.strftime("%Y-%m")
73
+
74
+ return os.path.join(settings.PERSISTENT_STORAGE_PATH, f"feedback_{date_str}.jsonl")
75
+
76
+ def save_feedback(payload: FeedbackPayload, client_ip: str):
77
+ os.makedirs(settings.PERSISTENT_STORAGE_PATH, exist_ok=True)
78
+ filename = get_partitioned_filename()
79
+ repo_filename = os.path.basename(filename)
80
+
81
+ if settings.HF_TOKEN and settings.HF_DATASET_REPO:
82
+ try:
83
+ from huggingface_hub import hf_hub_download
84
+ import shutil
85
+ if not os.path.exists(filename):
86
+ try:
87
+ downloaded_path = hf_hub_download(
88
+ repo_id=settings.HF_DATASET_REPO,
89
+ filename=repo_filename,
90
+ repo_type="dataset",
91
+ token=settings.HF_TOKEN
92
+ )
93
+ shutil.copy(downloaded_path, filename)
94
+ except Exception:
95
+ pass
96
+ except Exception as e:
97
+ print(f"Failed to sync baseline file from Hugging Face: {e}")
98
+
99
+ data = payload.dict()
100
+ data["timestamp"] = datetime.utcnow().isoformat()
101
+ data["client_ip_hash"] = hash(client_ip)
102
+
103
+ data["ensemble_margin_threshold"] = settings.ENSEMBLE_MARGIN_THRESHOLD
104
+ data["ensemble_logit_threshold"] = settings.ENSEMBLE_LOGIT_THRESHOLD
105
+
106
+ metrics = prediction_metrics_cache.pop(payload.image_hash, None)
107
+ if metrics:
108
+ data["ensemble_metrics"] = metrics
109
+
110
+ with open(filename, "a", encoding="utf-8") as f:
111
+ f.write(json.dumps(data) + "\n")
112
+
113
+ if settings.HF_TOKEN and settings.HF_DATASET_REPO:
114
+ try:
115
+ from huggingface_hub import HfApi
116
+ api = HfApi(token=settings.HF_TOKEN)
117
+
118
+ repo_filename = os.path.basename(filename)
119
+
120
+ api.upload_file(
121
+ path_or_fileobj=filename,
122
+ path_in_repo=repo_filename,
123
+ repo_id=settings.HF_DATASET_REPO,
124
+ repo_type="dataset",
125
+ commit_message=f"Sync telemetry log {repo_filename} (Auto-Commit)"
126
+ )
127
+ except Exception as e:
128
+ print(f"Failed to sync telemetry to Hugging Face: {e}")
129
+
130
+ @router.post("/api/feedback")
131
+ async def receive_feedback(request: Request, payload: FeedbackPayload, background_tasks: BackgroundTasks):
132
+ client_ip = request.client.host if request.client else "unknown"
133
+ now = datetime.utcnow().timestamp()
134
+
135
+
136
+ user_requests = ip_request_counts.get(client_ip, [])
137
+ user_requests = [t for t in user_requests if now - t < 60]
138
+
139
+ if len(user_requests) >= 5:
140
+ raise HTTPException(status_code=429, detail="Muitas requisições. Tente novamente mais tarde.")
141
+
142
+ user_requests.append(now)
143
+ ip_request_counts[client_ip] = user_requests
144
+
145
+ if payload.user_correction_id is not None:
146
+ val = str(payload.user_correction_id).lower()
147
+ if val not in ["none", "unknown_pokemon"]:
148
+ try:
149
+ cid = int(payload.user_correction_id)
150
+ if cid < 1 or cid > 1025:
151
+ raise ValueError()
152
+ except ValueError:
153
+ raise HTTPException(status_code=400, detail="Invalid user_correction_id")
154
+
155
+ background_tasks.add_task(save_feedback, payload, client_ip)
156
+
157
+ return {"status": "success", "message": "Feedback computado de forma segura"}
backend/model.py CHANGED
@@ -2,6 +2,8 @@ import torch.nn as nn
2
  import torchvision.models as models
3
 
4
  class PokedexNet(nn.Module):
 
 
5
  def __init__(self, num_classes=1025):
6
  super(PokedexNet, self).__init__()
7
  self.backbone = models.resnet18(weights=None)
 
2
  import torchvision.models as models
3
 
4
  class PokedexNet(nn.Module):
5
+ """Modified ResNet-18 model architecture for fine-grained binary silhouette classification."""
6
+
7
  def __init__(self, num_classes=1025):
8
  super(PokedexNet, self).__init__()
9
  self.backbone = models.resnet18(weights=None)
backend/pokemon_service.py CHANGED
@@ -15,7 +15,7 @@ class PokemonService:
15
  self.database_path = database_path
16
  self.pokemon_data: List[Dict[str, Any]] = []
17
  self.id_to_name: Dict[int, str] = {}
18
- self.cache_lock = asyncio.Lock() # Asymmetric async lock to prevent race conditions on writes
19
  self._load_database()
20
 
21
  def _load_database(self) -> None:
@@ -25,8 +25,6 @@ class PokemonService:
25
  self.pokemon_data = json.load(f)
26
 
27
  for index, item in enumerate(self.pokemon_data):
28
- # The model outputs zero-indexed classes (0 to 1024)
29
- # The DB has IDs 1 to 1025. We map the zero-index to the name.
30
  self.id_to_name[index] = item["name"]
31
  except Exception as e:
32
  print(f"Failed to load database at {self.database_path}: {e}")
@@ -82,29 +80,23 @@ class PokemonService:
82
  """Fetches and caches types and stats from PokeAPI with thread-safe lock and TTL validation."""
83
  str_id = str(pokemon_id)
84
 
85
- # Acquire asyncio Lock to prevent multiple concurrent requests from triggering race conditions
86
  async with self.cache_lock:
87
  cache = self._load_cache()
88
 
89
- # Check cache with TTL and handle migration/backward compatibility
90
  if str_id in cache:
91
  entry = cache[str_id]
92
  if isinstance(entry, dict) and "cached_at" in entry and "data" in entry:
93
- # Valid schema: check TTL (default 30 days)
94
  if time.time() - entry["cached_at"] < settings.POKEAPI_CACHE_TTL:
95
  return entry["data"]
96
  elif not isinstance(entry, dict) or "cached_at" not in entry:
97
- # Backward compatibility / migration: old cache without timestamp.
98
- # Automatically migrate it to the new schema for future correctness
99
  migrated_entry = {
100
  "data": entry,
101
  "cached_at": time.time()
102
  }
103
  cache[str_id] = migrated_entry
104
  self._save_cache(cache)
105
- return entry # Return the data as is
106
 
107
- # Cache miss or expired: fetch fresh data from PokeAPI
108
  url = f"{settings.POKEAPI_BASE}/pokemon/{pokemon_id}/"
109
  try:
110
  async with httpx.AsyncClient(timeout=5.0) as client:
@@ -129,7 +121,6 @@ class PokemonService:
129
  "sprites": data.get("sprites", {})
130
  }
131
 
132
- # Store in cache with the new timestamp schema
133
  cache[str_id] = {
134
  "data": enriched_data,
135
  "cached_at": time.time()
 
15
  self.database_path = database_path
16
  self.pokemon_data: List[Dict[str, Any]] = []
17
  self.id_to_name: Dict[int, str] = {}
18
+ self.cache_lock = asyncio.Lock()
19
  self._load_database()
20
 
21
  def _load_database(self) -> None:
 
25
  self.pokemon_data = json.load(f)
26
 
27
  for index, item in enumerate(self.pokemon_data):
 
 
28
  self.id_to_name[index] = item["name"]
29
  except Exception as e:
30
  print(f"Failed to load database at {self.database_path}: {e}")
 
80
  """Fetches and caches types and stats from PokeAPI with thread-safe lock and TTL validation."""
81
  str_id = str(pokemon_id)
82
 
 
83
  async with self.cache_lock:
84
  cache = self._load_cache()
85
 
 
86
  if str_id in cache:
87
  entry = cache[str_id]
88
  if isinstance(entry, dict) and "cached_at" in entry and "data" in entry:
 
89
  if time.time() - entry["cached_at"] < settings.POKEAPI_CACHE_TTL:
90
  return entry["data"]
91
  elif not isinstance(entry, dict) or "cached_at" not in entry:
 
 
92
  migrated_entry = {
93
  "data": entry,
94
  "cached_at": time.time()
95
  }
96
  cache[str_id] = migrated_entry
97
  self._save_cache(cache)
98
+ return entry
99
 
 
100
  url = f"{settings.POKEAPI_BASE}/pokemon/{pokemon_id}/"
101
  try:
102
  async with httpx.AsyncClient(timeout=5.0) as client:
 
121
  "sprites": data.get("sprites", {})
122
  }
123
 
 
124
  cache[str_id] = {
125
  "data": enriched_data,
126
  "cached_at": time.time()
backend/predict.py CHANGED
@@ -3,7 +3,7 @@ import cv2
3
  import numpy as np
4
  import torch
5
  import torch.nn.functional as F
6
- from torchvision import transforms # type: ignore
7
  from PIL import Image
8
  from dataclasses import dataclass
9
  from typing import Tuple, List, Dict, Optional, Any
@@ -20,6 +20,11 @@ class PredictionResult:
20
  detected_source: str
21
  top_5: List[Dict[str, Any]]
22
  debug_silhouette_b64: Optional[str] = None
 
 
 
 
 
23
 
24
 
25
  def _center_and_pad(mask: np.ndarray) -> np.ndarray:
@@ -73,7 +78,6 @@ def _detect_anime_type(img_bgr: np.ndarray) -> str:
73
  hsv_crop = hsv[:, :work_w, :]
74
  gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
75
 
76
- # 1. Geroid Detection
77
  geroid_blue = cv2.inRange(hsv, np.array([90, 100, 100]), np.array([130, 255, 255]))
78
  dark_pixels = cv2.inRange(gray, np.array([0]), np.array([50]))
79
 
@@ -83,19 +87,16 @@ def _detect_anime_type(img_bgr: np.ndarray) -> str:
83
  if blue_bg_ratio > 0.20 and dark_ratio > 0.05:
84
  return "geroid"
85
 
86
- # 2. Monkepo Detection
87
  grey_mask = cv2.inRange(hsv_crop, np.array([0, 0, 60]), np.array([180, 60, 180]))
88
  grey_ratio = cv2.countNonZero(grey_mask) / (h * work_w)
89
  if grey_ratio > 0.05:
90
  return "monkepo"
91
 
92
- # 3. Solid high saturation blue (modern)
93
  solid_blue = cv2.inRange(hsv_crop, np.array([88, 160, 60]), np.array([135, 255, 215]))
94
  blue_ratio = cv2.countNonZero(solid_blue) / (h * work_w)
95
  if blue_ratio > 0.08:
96
  return "new"
97
 
98
- # 4. Vibrant red background (classic)
99
  red1 = cv2.inRange(hsv, np.array([0, 120, 120]), np.array([10, 255, 255]))
100
  red2 = cv2.inRange(hsv, np.array([165, 120, 120]), np.array([180, 255, 255]))
101
  red_ratio = cv2.countNonZero(cv2.bitwise_or(red1, red2)) / (h * w)
@@ -210,15 +211,20 @@ def _extract_geroid(img_bgr: np.ndarray) -> np.ndarray:
210
 
211
  def predict_from_bytes(
212
  file_bytes: bytes,
213
- model: PokedexNet,
214
  device: torch.device,
215
  id_to_name: Dict[int, str],
216
- include_debug: bool = False
 
 
217
  ) -> PredictionResult:
218
  """
219
  Main pipeline to process an image from bytes in memory and infer the Pokémon.
220
  Executes purely in-memory (Zero Disk Writes).
221
  """
 
 
 
222
  np_arr = np.frombuffer(file_bytes, np.uint8)
223
  img = cv2.imdecode(np_arr, cv2.IMREAD_UNCHANGED)
224
 
@@ -258,11 +264,42 @@ def predict_from_bytes(
258
 
259
  input_tensor = _mask_to_tensor(final_mask).to(device)
260
 
 
 
 
 
 
 
 
 
 
261
  with torch.no_grad():
262
- output = model(input_tensor)
263
- probabilities = F.softmax(output, dim=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- top_probs, top_indices = torch.topk(probabilities, 5, dim=1)
266
 
267
  top_5_list = []
268
  for i in range(5):
@@ -277,13 +314,34 @@ def predict_from_bytes(
277
  "confidence": prob
278
  })
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  best_pred = top_5_list[0]
281
 
282
  return PredictionResult(
283
- pokemon_id=int(best_pred["pokemon_id"]), # type: ignore
284
  name=str(best_pred["name"]),
285
- confidence=float(best_pred["confidence"]), # type: ignore
286
  detected_source=detected_source,
287
  top_5=top_5_list,
288
- debug_silhouette_b64=debug_b64
 
 
 
 
 
289
  )
 
3
  import numpy as np
4
  import torch
5
  import torch.nn.functional as F
6
+ from torchvision import transforms
7
  from PIL import Image
8
  from dataclasses import dataclass
9
  from typing import Tuple, List, Dict, Optional, Any
 
20
  detected_source: str
21
  top_5: List[Dict[str, Any]]
22
  debug_silhouette_b64: Optional[str] = None
23
+ status: str = "CONFIDENT_POKEMON"
24
+ votes: List[int] = None
25
+ models_in_doubt_count: int = 0
26
+ image_hash: str = ""
27
+ ensemble_metrics: Dict[str, Any] = None
28
 
29
 
30
  def _center_and_pad(mask: np.ndarray) -> np.ndarray:
 
78
  hsv_crop = hsv[:, :work_w, :]
79
  gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
80
 
 
81
  geroid_blue = cv2.inRange(hsv, np.array([90, 100, 100]), np.array([130, 255, 255]))
82
  dark_pixels = cv2.inRange(gray, np.array([0]), np.array([50]))
83
 
 
87
  if blue_bg_ratio > 0.20 and dark_ratio > 0.05:
88
  return "geroid"
89
 
 
90
  grey_mask = cv2.inRange(hsv_crop, np.array([0, 0, 60]), np.array([180, 60, 180]))
91
  grey_ratio = cv2.countNonZero(grey_mask) / (h * work_w)
92
  if grey_ratio > 0.05:
93
  return "monkepo"
94
 
 
95
  solid_blue = cv2.inRange(hsv_crop, np.array([88, 160, 60]), np.array([135, 255, 215]))
96
  blue_ratio = cv2.countNonZero(solid_blue) / (h * work_w)
97
  if blue_ratio > 0.08:
98
  return "new"
99
 
 
100
  red1 = cv2.inRange(hsv, np.array([0, 120, 120]), np.array([10, 255, 255]))
101
  red2 = cv2.inRange(hsv, np.array([165, 120, 120]), np.array([180, 255, 255]))
102
  red_ratio = cv2.countNonZero(cv2.bitwise_or(red1, red2)) / (h * w)
 
211
 
212
  def predict_from_bytes(
213
  file_bytes: bytes,
214
+ models: List[PokedexNet],
215
  device: torch.device,
216
  id_to_name: Dict[int, str],
217
+ include_debug: bool = False,
218
+ margin_threshold: float = 0.30,
219
+ logit_threshold: float = 5.0
220
  ) -> PredictionResult:
221
  """
222
  Main pipeline to process an image from bytes in memory and infer the Pokémon.
223
  Executes purely in-memory (Zero Disk Writes).
224
  """
225
+ import hashlib
226
+ image_hash = hashlib.sha256(file_bytes).hexdigest()
227
+
228
  np_arr = np.frombuffer(file_bytes, np.uint8)
229
  img = cv2.imdecode(np_arr, cv2.IMREAD_UNCHANGED)
230
 
 
264
 
265
  input_tensor = _mask_to_tensor(final_mask).to(device)
266
 
267
+ predictions = []
268
+ top_classes = []
269
+ models_in_doubt = 0
270
+
271
+ ensemble_metrics = {
272
+ "max_raw_logits": [],
273
+ "margins": []
274
+ }
275
+
276
  with torch.no_grad():
277
+ for i, model in enumerate(models):
278
+ output = model(input_tensor)
279
+
280
+ max_raw_logit = torch.max(output, dim=1)[0].item()
281
+
282
+ probs = F.softmax(output, dim=1)
283
+
284
+ top_probs_model, top_indices_model = torch.topk(probs, k=3, dim=1)
285
+
286
+ top1_prob = top_probs_model[0][0].item()
287
+ top2_prob = top_probs_model[0][1].item()
288
+ top1_class = top_indices_model[0][0].item()
289
+
290
+ predictions.append(probs)
291
+ top_classes.append(top1_class)
292
+
293
+ margin = top1_prob - top2_prob
294
+ if margin < margin_threshold:
295
+ models_in_doubt += 1
296
+
297
+ ensemble_metrics["max_raw_logits"].append(round(max_raw_logit, 4))
298
+ ensemble_metrics["margins"].append(round(margin, 4))
299
+
300
+ avg_probs = torch.mean(torch.stack(predictions), dim=0)
301
 
302
+ top_probs, top_indices = torch.topk(avg_probs, 5, dim=1)
303
 
304
  top_5_list = []
305
  for i in range(5):
 
314
  "confidence": prob
315
  })
316
 
317
+ unique_votes = set(top_classes)
318
+
319
+ mean_max_logit = sum(ensemble_metrics["max_raw_logits"]) / len(ensemble_metrics["max_raw_logits"]) if ensemble_metrics["max_raw_logits"] else 0.0
320
+
321
+ if mean_max_logit < logit_threshold:
322
+ status = "ABSTRACT_NOISE_DETECTED"
323
+ elif models_in_doubt >= 2:
324
+ status = "ANOMALY_DETECTED_DUE_TO_LOW_MARGIN"
325
+ else:
326
+ if len(unique_votes) == 1:
327
+ status = "CONFIDENT_POKEMON"
328
+ elif len(unique_votes) == 2:
329
+ status = "UNCERTAIN_POKEMON"
330
+ else:
331
+ status = "ANOMALY_DETECTED_DUE_TO_DISAGREEMENT"
332
+
333
  best_pred = top_5_list[0]
334
 
335
  return PredictionResult(
336
+ pokemon_id=int(best_pred["pokemon_id"]),
337
  name=str(best_pred["name"]),
338
+ confidence=float(best_pred["confidence"]),
339
  detected_source=detected_source,
340
  top_5=top_5_list,
341
+ debug_silhouette_b64=debug_b64,
342
+ status=status,
343
+ votes=top_classes,
344
+ models_in_doubt_count=models_in_doubt,
345
+ image_hash=image_hash,
346
+ ensemble_metrics=ensemble_metrics
347
  )
backend/rate_limiter.py CHANGED
@@ -16,22 +16,18 @@ class SimpleRateLimiter:
16
  def is_allowed(self, client_ip: str) -> bool:
17
  now = time.time()
18
  with self.lock:
19
- # Filter timestamps to keep only those within the sliding window
20
  self.client_records[client_ip] = [
21
  t for t in self.client_records[client_ip]
22
  if now - t < self.window_seconds
23
  ]
24
 
25
- # If request count exceeds the limit, deny request
26
  if len(self.client_records[client_ip]) >= self.requests_limit:
27
  return False
28
 
29
- # Otherwise, record the current request and allow
30
  self.client_records[client_ip].append(now)
31
  return True
32
 
33
 
34
- # Helper dependency factory to apply rate limiting to endpoints using FastAPI's Depends()
35
  def get_rate_limiter(requests_limit: int, window_seconds: int):
36
  limiter = SimpleRateLimiter(requests_limit, window_seconds)
37
 
 
16
  def is_allowed(self, client_ip: str) -> bool:
17
  now = time.time()
18
  with self.lock:
 
19
  self.client_records[client_ip] = [
20
  t for t in self.client_records[client_ip]
21
  if now - t < self.window_seconds
22
  ]
23
 
 
24
  if len(self.client_records[client_ip]) >= self.requests_limit:
25
  return False
26
 
 
27
  self.client_records[client_ip].append(now)
28
  return True
29
 
30
 
 
31
  def get_rate_limiter(requests_limit: int, window_seconds: int):
32
  limiter = SimpleRateLimiter(requests_limit, window_seconds)
33
 
backend/requirements.txt CHANGED
@@ -7,3 +7,4 @@ opencv-python-headless>=4.8.0
7
  numpy>=1.24.0
8
  Pillow>=10.0.0
9
  httpx>=0.27.0
 
 
7
  numpy>=1.24.0
8
  Pillow>=10.0.0
9
  httpx>=0.27.0
10
+ huggingface_hub>=0.22.0
frontend/package.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "name": "frontend",
3
- "version": "0.1.0",
4
  "private": true,
5
  "scripts": {
6
  "dev": "next dev",
 
1
  {
2
  "name": "frontend",
3
+ "version": "1.1.0",
4
  "private": true,
5
  "scripts": {
6
  "dev": "next dev",
frontend/src/app/globals.css CHANGED
@@ -139,6 +139,33 @@ h1, h2, h3, h4, h5, h6 {
139
  box-shadow: none;
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  /* Asymmetric Grid Layout */
143
  .main-layout {
144
  padding: 4rem 2rem;
 
139
  box-shadow: none;
140
  }
141
 
142
+ .danger-btn {
143
+ display: inline-block;
144
+ background: rgba(255, 77, 79, 0.1);
145
+ backdrop-filter: blur(10px);
146
+ border: 1px solid rgba(255, 77, 79, 0.5);
147
+ color: #ff4d4f;
148
+ text-decoration: none;
149
+ border-radius: 30px;
150
+ padding: 12px 24px;
151
+ font-family: var(--font-body);
152
+ font-weight: 600;
153
+ font-size: 0.95rem;
154
+ letter-spacing: 0.5px;
155
+ transition: all 0.3s ease;
156
+ box-shadow: 0 4px 15px rgba(255, 77, 79, 0.2);
157
+ cursor: pointer;
158
+ text-transform: uppercase;
159
+ }
160
+
161
+ .danger-btn:hover {
162
+ background: rgba(255, 77, 79, 0.2);
163
+ color: #fff;
164
+ border-color: rgba(255, 77, 79, 0.8);
165
+ transform: translateY(-2px);
166
+ box-shadow: 0 8px 25px rgba(255, 77, 79, 0.4);
167
+ }
168
+
169
  /* Asymmetric Grid Layout */
170
  .main-layout {
171
  padding: 4rem 2rem;
frontend/src/app/page.js CHANGED
@@ -6,9 +6,10 @@ import Hero from '../components/Hero';
6
  import UploadZone from '../components/UploadZone';
7
  import LoadingOverlay from '../components/LoadingOverlay';
8
  import ResultCard from '../components/ResultCard';
 
9
 
10
  export default function Home() {
11
- const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'success' | 'error'
12
  const [predictionData, setPredictionData] = useState(null);
13
  const [pokemonDetails, setPokemonDetails] = useState(null);
14
  const [selectedImage, setSelectedImage] = useState(null);
@@ -50,6 +51,10 @@ export default function Home() {
50
  const predictData = await predictRes.json();
51
  setPredictionData(predictData);
52
 
 
 
 
 
53
 
54
  const detailsRes = await fetch(`${API_BASE}/api/pokemon/${predictData.pokemon_id}`);
55
  let detailsData = null;
@@ -58,7 +63,6 @@ export default function Home() {
58
  setPokemonDetails(detailsData);
59
  }
60
 
61
-
62
  if (detailsData && detailsData.types && detailsData.types.length > 0) {
63
  const primaryType = detailsData.types[0];
64
  if (typeof window !== 'undefined') {
@@ -70,7 +74,6 @@ export default function Home() {
70
  }
71
  }
72
 
73
- // Preload image with max 2.0s timeout (No minimum delay, as fast as possible)
74
  if (detailsData && detailsData.sprite_url && typeof window !== 'undefined') {
75
  await new Promise((resolve) => {
76
  const img = new Image();
@@ -94,7 +97,7 @@ export default function Home() {
94
  }
95
 
96
  setStatus('success');
97
- if (predictData.confidence > 0.95 && typeof window !== 'undefined') {
98
  import('canvas-confetti').then((confetti) => {
99
  confetti.default({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
100
  });
@@ -122,12 +125,68 @@ export default function Home() {
122
  <main className="main-layout">
123
  <ParticlesBg ref={particlesRef} />
124
 
125
- {status === 'success' && predictionData ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  <div style={{ gridColumn: '1 / -1', width: '100%', display: 'flex', justifyContent: 'center' }}>
127
  <ResultCard
128
  predictionData={predictionData}
129
  pokemonDetails={pokemonDetails}
130
  onReset={handleReset}
 
 
 
 
 
 
 
 
 
 
131
  />
132
  </div>
133
  ) : (
 
6
  import UploadZone from '../components/UploadZone';
7
  import LoadingOverlay from '../components/LoadingOverlay';
8
  import ResultCard from '../components/ResultCard';
9
+ import FeedbackWidget from '../components/FeedbackWidget';
10
 
11
  export default function Home() {
12
+ const [status, setStatus] = useState('idle');
13
  const [predictionData, setPredictionData] = useState(null);
14
  const [pokemonDetails, setPokemonDetails] = useState(null);
15
  const [selectedImage, setSelectedImage] = useState(null);
 
51
  const predictData = await predictRes.json();
52
  setPredictionData(predictData);
53
 
54
+ if (predictData.status && (predictData.status.includes('ANOMALY_DETECTED') || predictData.status === 'ABSTRACT_NOISE_DETECTED')) {
55
+ setStatus('anomaly');
56
+ return;
57
+ }
58
 
59
  const detailsRes = await fetch(`${API_BASE}/api/pokemon/${predictData.pokemon_id}`);
60
  let detailsData = null;
 
63
  setPokemonDetails(detailsData);
64
  }
65
 
 
66
  if (detailsData && detailsData.types && detailsData.types.length > 0) {
67
  const primaryType = detailsData.types[0];
68
  if (typeof window !== 'undefined') {
 
74
  }
75
  }
76
 
 
77
  if (detailsData && detailsData.sprite_url && typeof window !== 'undefined') {
78
  await new Promise((resolve) => {
79
  const img = new Image();
 
97
  }
98
 
99
  setStatus('success');
100
+ if (predictData.status === 'CONFIDENT_POKEMON' && predictData.confidence > 0.95 && typeof window !== 'undefined') {
101
  import('canvas-confetti').then((confetti) => {
102
  confetti.default({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
103
  });
 
125
  <main className="main-layout">
126
  <ParticlesBg ref={particlesRef} />
127
 
128
+ {status === 'anomaly' && predictionData ? (
129
+ <div style={{ gridColumn: '1 / -1', width: '100%', display: 'flex', justifyContent: 'center' }}>
130
+ <div className="glass-panel" style={{ textAlign: 'center', padding: '3rem', maxWidth: '650px', animation: 'fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards', borderTop: '4px solid #ff4d4f' }}>
131
+ <h2 style={{ color: '#ff4d4f', fontSize: '2.2rem', marginBottom: '1.5rem', fontWeight: '700', letterSpacing: '-0.5px' }}>
132
+ Out-of-Distribution Detected
133
+ </h2>
134
+ <p style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: '1.2rem', marginBottom: predictionData.debug_silhouette ? '1.5rem' : '2.5rem', lineHeight: '1.6' }}>
135
+ Unfortunately, PokedexNet cannot classify this image as any of the 1,025 known Pokémon species. Our security ensemble intercepted the request to prevent false positives.
136
+ </p>
137
+
138
+ {predictionData.debug_silhouette && (
139
+ <div style={{ backgroundColor: 'rgba(0,0,0,0.3)', padding: '1.5rem', borderRadius: '8px', marginBottom: '2.5rem', textAlign: 'left', borderLeft: '4px solid var(--accent)' }}>
140
+ <span style={{ display: 'block', color: 'var(--accent)', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', fontSize: '0.85rem', letterSpacing: '1px' }}>Ensemble Telemetry</span>
141
+ <span style={{ color: 'rgba(255, 255, 255, 0.7)', fontSize: '1rem', fontStyle: 'italic' }}>
142
+ {predictionData.status === 'ABSTRACT_NOISE_DETECTED'
143
+ ? 'Reason: Abstract Noise. The neural network failed to recognize any structural features or geometry. The image appears to be a blur, a basic geometric shape, or an abstract pattern.'
144
+ : predictionData.status === 'ANOMALY_DETECTED_DUE_TO_LOW_MARGIN'
145
+ ? `Reason: Predictive Entropy. The silhouette is excessively noisy or atypical, causing deep internal uncertainty (Low Confidence Margin detected in ${predictionData.models_in_doubt_count}/3 models).`
146
+ : 'Reason: Epistemic Uncertainty. The silhouette caused an extreme contradiction where our three models completely disagreed with each other.'}
147
+ </span>
148
+ </div>
149
+ )}
150
+
151
+ <button className="danger-btn" onClick={handleReset} style={{ marginTop: '0.5rem', marginBottom: '1.5rem' }}>
152
+ Scan New Subject
153
+ </button>
154
+
155
+ <FeedbackWidget
156
+ imageHash={predictionData.image_hash}
157
+ predictedId={null}
158
+ top5={null}
159
+ isOodScreen={true}
160
+ onFeedbackSuccess={(isCorrect) => {
161
+ if (isCorrect) {
162
+ if (particlesRef.current) {
163
+ particlesRef.current.setAccentColor('#38bdf8');
164
+ }
165
+ } else {
166
+ if (particlesRef.current) {
167
+ particlesRef.current.setAccentColor('#ff4d4f');
168
+ }
169
+ }
170
+ }}
171
+ />
172
+ </div>
173
+ </div>
174
+ ) : status === 'success' && predictionData ? (
175
  <div style={{ gridColumn: '1 / -1', width: '100%', display: 'flex', justifyContent: 'center' }}>
176
  <ResultCard
177
  predictionData={predictionData}
178
  pokemonDetails={pokemonDetails}
179
  onReset={handleReset}
180
+ onShinyActivated={() => {
181
+ if (particlesRef.current) {
182
+ particlesRef.current.setAccentColor('#FFD700');
183
+ }
184
+ }}
185
+ onDislikeActivated={() => {
186
+ if (particlesRef.current) {
187
+ particlesRef.current.setAccentColor('#ff4d4f');
188
+ }
189
+ }}
190
  />
191
  </div>
192
  ) : (
frontend/src/app/research/page.js CHANGED
@@ -268,9 +268,9 @@ export default function ResearchPaper() {
268
  </section>
269
 
270
  <section className={styles.section}>
271
- <h2 className={styles.sectionTitle}>7. Discussion: Out-of-Distribution Behavior</h2>
272
  <p className={styles.paragraph}>
273
- Strong in-distribution performance tells only part of the story. Like all closed-set softmax classifiers, PokedexNet has no native mechanism to abstain or flag uncertainty when presented with an input outside its training distribution. To probe how the learned representations respond to an entirely foreign geometry, a silhouette from a different franchise — referred to here as &quot;Rhamphomon&quot; (see Figure 6) — was submitted to the model and the resulting probability distributions were examined.
274
  </p>
275
 
276
  <figure className={styles.figure} style={{ marginTop: '1rem', marginBottom: '2.5rem' }}>
@@ -278,17 +278,18 @@ export default function ResearchPaper() {
278
  <img src="/static/images/digimon.png" alt="Rhamphomon (Out-of-Distribution Input)" className={styles.image} style={{ maxWidth: '300px' }} />
279
  <figcaption className={styles.caption} style={{ marginTop: '1rem' }}>Figure 6: Rhamphomon, an Out-of-Distribution input used to test the model&apos;s structural abstraction capabilities.</figcaption>
280
  </figure>
 
281
  <p className={styles.paragraph}>
282
- Since the model must assign probability to some class, it maps the unknown geometry onto the nearest regions of its learned latent space. What is interesting is <em>how</em> it does so. Rather than collapsing to a single arbitrary prediction, the model identifies anatomically coherent matches: the crouching posture and appendage structure of the intruding silhouette draw probability toward species that share those same structural features.
283
  </p>
284
  <p className={styles.paragraph}>
285
- The pattern varies across seeds, as shown in Figure 7. Seeds 3 and 121 converge on Crobat as the top prediction, with confidence as high as 66.7%. Seeds 7, 25, and 255 shift the probability mass toward Purrloin, with flatter and more competitive distributions. The fact that the top predictions alternate between a crouching feline and a winged bat — structurally quite different species — reflects the ambiguity of the input: different random initializations emphasize different anatomical fragments of the same silhouette.
286
  </p>
287
  <p className={styles.paragraph}>
288
- Across all seeds, aerodynamically proportioned species such as Talonflame, Aerodactyl, and Kilowattrel appear consistently near the top of the confidence ranking. This is not coincidental: it suggests that the convolutional filters have internalized structural concepts like wingspan geometry and streamlined body ratios at a level of abstraction that transfers, at least partially, to novel organisms.
289
  </p>
290
  <p className={styles.paragraph}>
291
- The seed-dependent instability of the top prediction is itself informative. A confident, seed-stable prediction is a sign of genuine recognition; an unstable one — where the winning class flips depending on initialization — is a natural indicator of out-of-distribution input. This emergent behavior suggests a potential path toward lightweight anomaly detection without adding any explicit rejection mechanism to the classifier.
292
  </p>
293
 
294
  <figure className={styles.figure}>
@@ -324,7 +325,88 @@ export default function ResearchPaper() {
324
  </section>
325
 
326
  <section className={styles.section}>
327
- <h2 className={styles.sectionTitle}>8. Conclusion</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  <p className={styles.paragraph}>
329
  PokedexNet demonstrates that a modestly sized convolutional network, trained from scratch on binary silhouettes, can achieve over 80.8% Top-1 accuracy across 1,025 classes — relying entirely on geometric shape, without any color, texture, or contextual information. The stability of results across five independent seeds confirms that this is a robust outcome, not a product of favorable initialization. The consistent Macro F1 score shows the model generalizes equitably across rare and common body plans alike.
330
  </p>
@@ -332,7 +414,7 @@ export default function ResearchPaper() {
332
  The performance ceiling the model encounters is not a weakness of the architecture but a fundamental property of the problem: some species are geometrically indistinguishable under binary 2D projection, and no classifier can do better than chance on those pairs without additional visual information. The fact that errors cluster precisely on these degenerate cases validates both the preprocessing pipeline and the geometric focus of the learned representations.
333
  </p>
334
  <p className={styles.paragraph}>
335
- Out-of-distribution experiments revealed that the model&apos;s anatomical abstractions are coherent enough to produce structured, morphologically grounded predictions even for unseen organisms. The cross-seed instability of OOD predictions offers a promising passive signal for anomaly detection a direction worth exploring in future work alongside explicit open-set recognition mechanisms.
336
  </p>
337
  </section>
338
 
@@ -349,6 +431,31 @@ export default function ResearchPaper() {
349
  </ul>
350
  </section>
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  </article>
353
  </div>
354
  );
 
268
  </section>
269
 
270
  <section className={styles.section}>
271
+ <h2 className={styles.sectionTitle}>7. Discussion: Out-of-Distribution Behavior and Open-Set Recognition</h2>
272
  <p className={styles.paragraph}>
273
+ Strong in-distribution performance tells only part of the story. Like all closed-set softmax classifiers, PokedexNet has no native mechanism to abstain or flag uncertainty when presented with an input outside its training distribution. To probe how the learned representations respond to an entirely foreign geometry — and to establish the empirical basis for the open-set mitigation strategy described below — a silhouette drawn from the Digimon franchise — specifically, Rhamphomon (see Figure 6) — was submitted to the model and the resulting probability distributions examined across all five seeds.
274
  </p>
275
 
276
  <figure className={styles.figure} style={{ marginTop: '1rem', marginBottom: '2.5rem' }}>
 
278
  <img src="/static/images/digimon.png" alt="Rhamphomon (Out-of-Distribution Input)" className={styles.image} style={{ maxWidth: '300px' }} />
279
  <figcaption className={styles.caption} style={{ marginTop: '1rem' }}>Figure 6: Rhamphomon, an Out-of-Distribution input used to test the model&apos;s structural abstraction capabilities.</figcaption>
280
  </figure>
281
+
282
  <p className={styles.paragraph}>
283
+ Since the model must assign probability to some class, it maps the unknown geometry onto the nearest regions of its learned latent space. What is revealing is <em>how</em> it does so. Rather than collapsing to a single arbitrary prediction, the model identifies anatomically coherent matches: the crouching posture and appendage structure of the intruding silhouette draw probability toward species that share those same structural features.
284
  </p>
285
  <p className={styles.paragraph}>
286
+ The pattern varies across seeds, as shown in Figure 7. Seeds 3 and 121 converge on Crobat as the top prediction, with confidence as high as 66.7%. Seeds 7, 25, and 255 shift the probability mass toward Purrloin, with flatter and more competitive distributions. The fact that the top prediction alternates between a crouching feline and a winged bat — structurally quite different species — reflects the geometric ambiguity inherent to the input: different random initializations emphasize different anatomical fragments of the same silhouette, producing structurally coherent but mutually inconsistent predictions.
287
  </p>
288
  <p className={styles.paragraph}>
289
+ Across all seeds, aerodynamically proportioned species such as Talonflame, Aerodactyl, and Kilowattrel appear consistently near the top of the confidence ranking. This is not coincidental: it indicates that the convolutional filters have internalized structural abstractions wingspan geometry, streamlined body ratios that generalize, at least partially, to geometries outside the training distribution.
290
  </p>
291
  <p className={styles.paragraph}>
292
+ The seed-dependent instability of the top prediction is itself the central diagnostic signal. A confident, seed-stable prediction is the fingerprint of genuine recognition; a prediction that flips between initializations is a natural indicator of out-of-distribution input. This emergent property directly motivates the two-stage open-set pipeline described in Section 8: the first stage intercepts inputs that fail to activate the network&apos;s geometric filters; the second quantifies the epistemic disagreement that characterizes inputs — like Rhamphomon — that pass the activation threshold yet remain outside the manifold of learned morphologies.
293
  </p>
294
 
295
  <figure className={styles.figure}>
 
325
  </section>
326
 
327
  <section className={styles.section}>
328
+ <h2 className={styles.sectionTitle}>8. Open-Set Recognition and Production Telemetry</h2>
329
+ <p className={styles.paragraph}>
330
+ Transitioning a classifier from an isolated evaluation environment to open-domain inference exposes the fundamental limitation of the closed-world assumption. Standard softmax functions force the output probability distribution to sum to 1.0, often producing severe overconfidence when the network is confronted with OOD data, random noise, or geometrically degenerate inputs. The OOD experiments in Section 7 make this pathology concrete: Rhamphomon elicits strong activations and confident predictions, yet those predictions are unstable across seeds — a combination that is structurally impossible for any in-distribution input. Mitigating this without altering the underlying ResNet-18 architecture requires operating at two distinct levels of the inference stack.
331
+ </p>
332
+
333
+ <h3 className={styles.subsectionTitle}>8.1. Stage One — Mitigating Softmax Overconfidence via Logit Thresholding</h3>
334
+ <p className={styles.paragraph}>
335
+ When presented with abstract noise or real-world photographs lacking the topological structure of the training domain, the convolutional filters fail to activate strongly. The softmax function, however, artificially inflates these weak activations into high-confidence predictions — a well-documented failure mode of closed-set classifiers.
336
+ </p>
337
+ <p className={styles.paragraph}>
338
+ To intercept these inputs before normalization, the system bypasses the softmax layer and evaluates the raw pre-activation signal strength directly. A Soft Voting mechanism computes the mean of the maximum raw logits across the ensemble of <span className="mono">N</span> models:
339
+ </p>
340
+
341
+ <div className={styles.equationWrapper}>
342
+ <div className={styles.equation}>
343
+ μ<sub>logit</sub> &nbsp;=&nbsp;
344
+ <span className={styles.fraction}>
345
+ <span className={styles.numerator}>1</span>
346
+ <span className={styles.denominator}>N</span>
347
+ </span>
348
+ <span className={styles.mathSymbol}>∑</span>
349
+ <span className={styles.subSuper}>
350
+ <span className={styles.super}>N</span>
351
+ <span className={styles.sub}>i=1</span>
352
+ </span>
353
+ &nbsp;max(Z<sub>i</sub>)
354
+ </div>
355
+ <span className={styles.equationNumber}>(1)</span>
356
+ </div>
357
+
358
+ <p className={styles.paragraph}>
359
+ where <span className="mono">Z<sub>i</sub></span> represents the logit vector from the <span className="mono">i</span>-th model. Empirical calibration demonstrated that true in-distribution silhouettes consistently generate strong activations (<span className="mono">μ<sub>logit</sub> &gt; 9.0</span>), whereas abstract shapes and real-world photographs yield significantly weaker signals. Enforcing a threshold of <span className="mono">μ<sub>logit</sub> &lt; 6.5</span> effectively discards inputs that lack the minimal geometric complexity required for classification — before they reach the softmax normalization phase that would otherwise manufacture false confidence.
360
+ </p>
361
+ <p className={styles.paragraph}>
362
+ The production ensemble was composed of three models selected from the five trained seeds — specifically, seeds 3, 7, and 25 — chosen for their balanced performance profile and representational diversity. The distinction between hard and soft voting is consequential under this configuration. Under a hard voting rule, a single underperforming model is sufficient to suppress an otherwise valid prediction; under mean aggregation, a model with strong activation can compensate for a weaker one, provided the overall signal is genuine. Calibration exposed a concrete instance of this asymmetry: a dorsal silhouette of Kricketune — an atypical orthographic projection underrepresented in the training corpus — produced individual logits of 5.49, 6.42, and 9.63 across the three models. Hard voting would have blocked it — two of three models fall below the 6.5 threshold. Mean aggregation yields <span className="mono">μ<sub>logit</sub> = 7.18</span>, correctly identifying the ensemble&apos;s aggregate certainty as sufficient to pass Stage One. The third model, activating at 9.63, functions as a stabilizing vote: its strong geometric response pulls the mean above threshold and recovers the input that the two weaker models would have discarded. This behavior is precisely what the Soft Voting mechanism is designed to produce — minority confidence preserved, rather than overruled, by the ensemble. That the same input subsequently triggered an Uncertainty Warning at Stage Two — where the confidence margin of the weakest model collapsed to 0.2387, below the 0.30 threshold — and was correctly resolved only after telemetry-driven retraining, is a point returned to in Section 8.3.
363
+ </p>
364
+
365
+ <h3 className={styles.subsectionTitle}>8.2. Stage Two — Predictive Entropy and the Confidence Margin</h3>
366
+ <p className={styles.paragraph}>
367
+ Inputs that clear the logit threshold — complex OOD silhouettes, characters from other franchises, or severe geometric clones — require a secondary filter operating on a different signal: not activation strength, but epistemic agreement across the ensemble.
368
+ </p>
369
+ <p className={styles.paragraph}>
370
+ This uncertainty is quantified via the Confidence Margin (<span className="mono">M</span>) between the Top-1 and Top-2 softmax probabilities for each model <span className="mono">i</span>:
371
+ </p>
372
+
373
+ <div className={styles.equationWrapper}>
374
+ <div className={styles.equation}>
375
+ M<sub>i</sub> &nbsp;=&nbsp; P(y<sub>1, i</sub>) &nbsp;−&nbsp; P(y<sub>2, i</sub>)
376
+ </div>
377
+ <span className={styles.equationNumber}>(2)</span>
378
+ </div>
379
+
380
+ <p className={styles.paragraph}>
381
+ Unambiguous in-distribution silhouettes generate overwhelming margins (<span className="mono">M<sub>i</sub> &gt; 0.70</span>). When confronted with a complex anomaly or an ambiguous topological clone, the models distribute probability mass across multiple competing classes and the margin collapses (<span className="mono">M<sub>i</sub> &lt; 0.30</span>). The Rhamphomon experiments make this concrete: not only do margins fall below threshold across all five seeds, but the winning class itself changes between initializations — precisely the instability pattern identified in Section 7 as the diagnostic signature of OOD input.
382
+ </p>
383
+ <p className={styles.paragraph}>
384
+ When the majority of the ensemble exhibits a low margin, or when individual models disagree on the predicted class, the system intercepts the prediction and issues an <em>Uncertainty Warning</em> rather than a hard rejection — indicating that the silhouette is atypical, heavily occluded, or morphologically ambiguous.
385
+ </p>
386
+
387
+ <h3 className={styles.subsectionTitle}>8.3. Telemetry and the Active Learning Flywheel</h3>
388
+ <p className={styles.paragraph}>
389
+ The two-stage filter identifies edge cases; the telemetry pipeline resolves them. To systematically address failure modes such as pose bias — where dorsal or atypical orthographic projections map poorly to the dominant frontal features learned during training — a feedback loop was integrated into the inference pipeline.
390
+ </p>
391
+ <p className={styles.paragraph}>
392
+ Users are presented with the option to validate or correct uncertain predictions. Each telemetry payload records the original input hash, the ensemble thresholds active at the time of inference, and an MD5 cryptographic hash of the specific <span className="mono">.pth</span> weight files used — ensuring that every hard negative can be traced back to the exact model state that produced it.
393
+ </p>
394
+ <p className={styles.paragraph}>
395
+ The Kricketune dorsal case, introduced in Section 8.1, provides a concrete end-to-end demonstration of this cycle. The image — uniquely identified by the perceptual hash <span className="mono">2887cb6a...</span> — passed Stage One with <span className="mono">μ<sub>logit</sub> = 7.18</span> but triggered an Uncertainty Warning at Stage Two, where one model produced a confidence margin of 0.2387, below the 0.30 threshold. Unable to confirm the prediction, the user submitted a correction flagged as <span className="mono">unknown_pokemon</span>. The telemetry payload captured the full inference context: input hash, per-model logits and margins, active thresholds, and the MD5 hashes of the three <span className="mono">.pth</span> weight files comprising the ensemble at that moment. This record was ingested as a hard negative and queued for the subsequent retraining cycle.
396
+ </p>
397
+ <p className={styles.paragraph}>
398
+ We used this log to review the metric. The ensemble thresholds were examined against the per-model activation patterns, confirming that the Stage Two margin criterion had correctly isolated the uncertain model without penalizing the two confident ones. The review validated that the pipeline&apos;s decision to escalate rather than discard was appropriate.
399
+ </p>
400
+ <p className={styles.paragraph}>
401
+ When the identical image was resubmitted after retraining, the convolutional backbone — the three ResNet-18 feature extractors — remained unchanged, producing the same raw feature maps as before. The retraining targeted only the final classifier layers, which were fine-tuned on the augmented dataset that included the newly labeled dorsal pose. The log confirmed the outcome: <span className="mono">predicted_id: 402</span> (Kricketune), <span className="mono">is_correct: true</span>, <span className="mono">is_ood_screen: false</span>. The Uncertainty Warning was not triggered. A pose variant that the system had been unable to classify earlier was now handled silently and correctly.
402
+ </p>
403
+ <p className={styles.paragraph}>
404
+ This is not an illustrative scenario — it is a logged event, reproducible from the archived telemetry. It demonstrates that this continuous ingestion of ambiguous projections forms an active learning flywheel: subsequent iterations progressively resolve spatial collisions within the latent space, turning the edge cases that the current model cannot decide into the training signal that the next one learns from.
405
+ </p>
406
+ </section>
407
+
408
+ <section className={styles.section}>
409
+ <h2 className={styles.sectionTitle}>9. Conclusion</h2>
410
  <p className={styles.paragraph}>
411
  PokedexNet demonstrates that a modestly sized convolutional network, trained from scratch on binary silhouettes, can achieve over 80.8% Top-1 accuracy across 1,025 classes — relying entirely on geometric shape, without any color, texture, or contextual information. The stability of results across five independent seeds confirms that this is a robust outcome, not a product of favorable initialization. The consistent Macro F1 score shows the model generalizes equitably across rare and common body plans alike.
412
  </p>
 
414
  The performance ceiling the model encounters is not a weakness of the architecture but a fundamental property of the problem: some species are geometrically indistinguishable under binary 2D projection, and no classifier can do better than chance on those pairs without additional visual information. The fact that errors cluster precisely on these degenerate cases validates both the preprocessing pipeline and the geometric focus of the learned representations.
415
  </p>
416
  <p className={styles.paragraph}>
417
+ Finally, the deployment of dual-stage entropy filtering—utilizing both raw logit thresholds and softmax confidence margins—demonstrates that closed-set classifiers can be adapted for safe open-world inference. Coupled with a persistent active learning telemetry pipeline, PokedexNet transcends static evaluation, establishing a robust framework for continuous morphological learning in computer vision.
418
  </p>
419
  </section>
420
 
 
431
  </ul>
432
  </section>
433
 
434
+ <section className={styles.section}>
435
+ <h2 className={styles.sectionTitle}>List of Equations</h2>
436
+ <ul className={styles.list}>
437
+ <li className={styles.listItem}>
438
+ <strong>Equation (1): Soft-Voting Ensemble Mean Maximum Logit</strong>
439
+ <ul style={{ marginTop: '0.5rem', marginLeft: '1.5rem', listStyleType: 'circle' }}>
440
+ <li style={{ marginBottom: '0.25rem' }}><strong>Meaning:</strong> Bypasses the softmax layer to evaluate raw activation strength directly before normalization, filtering out abstract shapes or out-of-distribution inputs that fail to stimulate the convolutional feature extractors.</li>
441
+ <li style={{ marginBottom: '0.25rem' }}><strong>μ<sub>logit</sub>:</strong> The calculated mean of the maximum raw logit outputs across all models in the ensemble, used as the first-stage activation filter.</li>
442
+ <li style={{ marginBottom: '0.25rem' }}><strong>N:</strong> The total number of model instances in the ensemble (in production, N = 3).</li>
443
+ <li style={{ marginBottom: '0.25rem' }}><strong>Z<sub>i</sub>:</strong> The vector of pre-activation logit outputs produced by the fully connected classification head of the <em>i</em>-th model instance.</li>
444
+ <li style={{ marginBottom: '0.25rem' }}><strong>max(Z<sub>i</sub>):</strong> The highest raw output value in the logit vector for the <em>i</em>-th model, indicating that model&apos;s strongest localized feature response.</li>
445
+ </ul>
446
+ </li>
447
+ <li className={styles.listItem} style={{ marginTop: '1.5rem' }}>
448
+ <strong>Equation (2): Single-Model Softmax Confidence Margin</strong>
449
+ <ul style={{ marginTop: '0.5rem', marginLeft: '1.5rem', listStyleType: 'circle' }}>
450
+ <li style={{ marginBottom: '0.25rem' }}><strong>Meaning:</strong> Measures epistemic certainty on in-distribution silhouettes by calculating the probability gap between the top two classifications. A low gap indicates class ambiguity, signaling high model uncertainty.</li>
451
+ <li style={{ marginBottom: '0.25rem' }}><strong>M<sub>i</sub>:</strong> The confidence margin score calculated for the <em>i</em>-th model instance, representing its predictive certainty.</li>
452
+ <li style={{ marginBottom: '0.25rem' }}><strong>P(y<sub>1, i</sub>):</strong> The highest softmax probability score assigned to the top predicted Pokémon class by the <em>i</em>-th model instance.</li>
453
+ <li style={{ marginBottom: '0.25rem' }}><strong>P(y<sub>2, i</sub>):</strong> The second-highest softmax probability score assigned to the runner-up predicted Pokémon class by the <em>i</em>-th model instance.</li>
454
+ </ul>
455
+ </li>
456
+ </ul>
457
+ </section>
458
+
459
  </article>
460
  </div>
461
  );
frontend/src/app/research/page.module.css CHANGED
@@ -297,6 +297,83 @@
297
  transform: scale(1.02);
298
  }
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  @media (max-width: 768px) {
301
  .oodContainer {
302
  flex-direction: column;
 
297
  transform: scale(1.02);
298
  }
299
 
300
+ .equationWrapper {
301
+ display: flex;
302
+ justify-content: center;
303
+ align-items: center;
304
+ width: 100%;
305
+ margin: 2rem 0;
306
+ position: relative;
307
+ }
308
+
309
+ .equation {
310
+ display: flex;
311
+ justify-content: center;
312
+ align-items: center;
313
+ font-family: var(--font-mono);
314
+ font-size: 1.38rem;
315
+ color: var(--accent-primary);
316
+ padding: 1rem 2rem;
317
+ background: rgba(255, 255, 255, 0.02);
318
+ border-radius: 12px;
319
+ border: 1px solid var(--border-glass);
320
+ max-width: fit-content;
321
+ box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.05);
322
+ margin: 0;
323
+ }
324
+
325
+ .equationNumber {
326
+ position: absolute;
327
+ right: 1rem;
328
+ font-family: var(--font-mono);
329
+ font-size: 1.1rem;
330
+ color: var(--text-secondary);
331
+ font-weight: 700;
332
+ }
333
+
334
+ .fraction {
335
+ display: inline-flex;
336
+ flex-direction: column;
337
+ align-items: center;
338
+ vertical-align: middle;
339
+ margin: 0 0.5rem;
340
+ font-size: 1.08rem;
341
+ line-height: 1.1;
342
+ }
343
+
344
+ .numerator {
345
+ border-bottom: 1px solid var(--accent-primary);
346
+ padding: 0 0.2rem;
347
+ text-align: center;
348
+ }
349
+
350
+ .denominator {
351
+ padding: 0 0.2rem;
352
+ text-align: center;
353
+ }
354
+
355
+ .mathSymbol {
356
+ font-size: 1.56rem;
357
+ margin: 0 0.25rem;
358
+ }
359
+
360
+ .subSuper {
361
+ display: inline-flex;
362
+ flex-direction: column;
363
+ font-size: 0.78rem;
364
+ vertical-align: middle;
365
+ margin-left: 0.1rem;
366
+ line-height: 1;
367
+ }
368
+
369
+ .super {
370
+ margin-bottom: 2px;
371
+ }
372
+
373
+ .sub {
374
+ margin-top: 2px;
375
+ }
376
+
377
  @media (max-width: 768px) {
378
  .oodContainer {
379
  flex-direction: column;
frontend/src/components/ConfidenceBar.js CHANGED
@@ -7,7 +7,6 @@ export default function ConfidenceBar({ confidence }) {
7
  const percentage = (confidence * 100).toFixed(1);
8
 
9
  useEffect(() => {
10
- // Reset and animate to width
11
  setWidth(0);
12
  const timer = setTimeout(() => setWidth(percentage), 100);
13
  return () => clearTimeout(timer);
 
7
  const percentage = (confidence * 100).toFixed(1);
8
 
9
  useEffect(() => {
 
10
  setWidth(0);
11
  const timer = setTimeout(() => setWidth(percentage), 100);
12
  return () => clearTimeout(timer);
frontend/src/components/FeedbackWidget.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import { useState } from 'react';
3
+ import styles from './FeedbackWidget.module.css';
4
+
5
+ export default function FeedbackWidget({ imageHash, predictedId, top5, isOodScreen, onFeedbackSuccess }) {
6
+ const [feedbackState, setFeedbackState] = useState('idle');
7
+ const [errorMessage, setErrorMessage] = useState('');
8
+
9
+ const sendFeedback = async (isCorrect, userCorrectionId = null) => {
10
+ setFeedbackState('submitting');
11
+ try {
12
+ const response = await fetch('/api/feedback', {
13
+ method: 'POST',
14
+ headers: { 'Content-Type': 'application/json' },
15
+ body: JSON.stringify({
16
+ image_hash: imageHash,
17
+ predicted_id: predictedId,
18
+ is_correct: isCorrect,
19
+ user_correction_id: userCorrectionId,
20
+ is_ood_screen: isOodScreen || false
21
+ })
22
+ });
23
+
24
+ if (!response.ok) {
25
+ if (response.status === 429) {
26
+ throw new Error("Muitas requisições. Tente novamente mais tarde.");
27
+ }
28
+ throw new Error("Erro ao salvar feedback");
29
+ }
30
+ setFeedbackState('success');
31
+ if (onFeedbackSuccess) {
32
+ onFeedbackSuccess(isCorrect);
33
+ }
34
+ setTimeout(() => {
35
+ setFeedbackState('hidden');
36
+ }, 3000);
37
+ } catch (error) {
38
+ setFeedbackState('error');
39
+ setErrorMessage(error.message);
40
+ setTimeout(() => setFeedbackState('idle'), 4000);
41
+ }
42
+ };
43
+
44
+ if (feedbackState === 'hidden') {
45
+ return null;
46
+ }
47
+
48
+ if (feedbackState === 'success') {
49
+ return (
50
+ <div className={styles.successMessage}>
51
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{marginRight: '8px', color: '#4caf50'}}><polyline points="20 6 9 17 4 12"></polyline></svg>
52
+ Feedback sent, thank you!
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div className={styles.feedbackContainer}>
59
+ {feedbackState === 'idle' || feedbackState === 'error' ? (
60
+ <div className={styles.voteButtons}>
61
+ <span className={styles.prompt}>
62
+ {isOodScreen ? "Did PokedexNet get this right?" : "Was this prediction accurate?"}
63
+ </span>
64
+ <button
65
+ className={`${styles.voteBtn} ${styles.likeBtn}`}
66
+ onClick={() => sendFeedback(true, isOodScreen ? 'none' : null)}
67
+ title={isOodScreen ? "Yes, it is NOT a Pokémon" : "Yes, it's correct"}
68
+ >
69
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path></svg>
70
+ </button>
71
+ <button
72
+ className={`${styles.voteBtn} ${styles.dislikeBtn}`}
73
+ onClick={() => isOodScreen ? sendFeedback(false, 'unknown_pokemon') : setFeedbackState('dislike_menu')}
74
+ title={isOodScreen ? "No, it actually IS a Pokémon" : "No, it's wrong"}
75
+ >
76
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path></svg>
77
+ </button>
78
+ {feedbackState === 'error' && <span className={styles.errorText}>{errorMessage}</span>}
79
+ </div>
80
+ ) : feedbackState === 'submitting' ? (
81
+ <div className={styles.loadingMessage}>Sending feedback...</div>
82
+ ) : feedbackState === 'dislike_menu' ? (
83
+ <div className={styles.dislikeMenu}>
84
+ <span className={styles.prompt}>What Pokémon is it really?</span>
85
+ <div className={styles.optionsGrid}>
86
+ {top5 && top5.slice(1).map(pred => (
87
+ <button
88
+ key={pred.pokemon_id}
89
+ className={styles.optionBtn}
90
+ onClick={() => sendFeedback(false, pred.pokemon_id)}
91
+ >
92
+ {pred.name}
93
+ </button>
94
+ ))}
95
+ <button
96
+ className={`${styles.optionBtn} ${styles.noneBtn}`}
97
+ onClick={() => sendFeedback(false, 'none')}
98
+ >
99
+ None of these
100
+ </button>
101
+ </div>
102
+ <button
103
+ className={styles.cancelBtn}
104
+ onClick={() => setFeedbackState('idle')}
105
+ >
106
+ Cancel
107
+ </button>
108
+ </div>
109
+ ) : null}
110
+ </div>
111
+ );
112
+ }
frontend/src/components/FeedbackWidget.module.css ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .feedbackContainer {
2
+ margin-top: 1.5rem;
3
+ padding: 1rem;
4
+ background: rgba(255, 255, 255, 0.03);
5
+ border-radius: 12px;
6
+ border: 1px solid rgba(255, 255, 255, 0.1);
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: center;
10
+ justify-content: center;
11
+ transition: all 0.3s ease;
12
+ }
13
+
14
+ .voteButtons {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 1rem;
18
+ }
19
+
20
+ .prompt {
21
+ color: rgba(255, 255, 255, 0.8);
22
+ font-size: 0.95rem;
23
+ font-weight: 500;
24
+ }
25
+
26
+ .voteBtn {
27
+ background: rgba(255, 255, 255, 0.05);
28
+ border: 1px solid rgba(255, 255, 255, 0.1);
29
+ color: rgba(255, 255, 255, 0.9); /* Símbolo branco por padrão (não preto) */
30
+ width: 44px;
31
+ height: 44px;
32
+ border-radius: 50%;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ cursor: pointer;
37
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
38
+ }
39
+
40
+ .voteBtn svg {
41
+ stroke: currentColor;
42
+ }
43
+
44
+ .likeBtn:hover {
45
+ background: var(--accent-primary); /* Azul idêntico ao do botão de exit */
46
+ border-color: var(--accent-primary);
47
+ color: #fff; /* Símbolo branco sólido no hover */
48
+ transform: scale(1.1);
49
+ }
50
+
51
+ .dislikeBtn:hover {
52
+ background: #ff4d4f; /* Vermelho idêntico ao retry do OOD */
53
+ border-color: #ff4d4f;
54
+ color: #fff; /* Símbolo branco sólido no hover */
55
+ transform: scale(1.1);
56
+ }
57
+
58
+ .voteBtn:active {
59
+ transform: scale(0.95);
60
+ }
61
+
62
+ .successMessage {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 0.5rem;
66
+ color: #52E028;
67
+ font-weight: 600;
68
+ animation: fadeIn 0.4s ease;
69
+ padding: 1rem;
70
+ }
71
+
72
+ .checkIcon {
73
+ background: rgba(82, 224, 40, 0.2);
74
+ border-radius: 50%;
75
+ width: 24px;
76
+ height: 24px;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ }
81
+
82
+ .loadingMessage {
83
+ color: rgba(255, 255, 255, 0.6);
84
+ font-style: italic;
85
+ font-size: 0.9rem;
86
+ }
87
+
88
+ .errorText {
89
+ color: #ff4d4f;
90
+ font-size: 0.85rem;
91
+ margin-left: 0.5rem;
92
+ }
93
+
94
+ .dislikeMenu {
95
+ display: flex;
96
+ flex-direction: column;
97
+ align-items: center;
98
+ width: 100%;
99
+ animation: slideDown 0.3s ease forwards;
100
+ }
101
+
102
+ .optionsGrid {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ gap: 0.5rem;
106
+ justify-content: center;
107
+ margin: 1rem 0;
108
+ width: 100%;
109
+ }
110
+
111
+ .optionBtn {
112
+ background: rgba(255, 255, 255, 0.05);
113
+ border: 1px solid rgba(255, 255, 255, 0.15);
114
+ color: #fff;
115
+ padding: 0.5rem 1rem;
116
+ border-radius: 20px;
117
+ font-size: 0.85rem;
118
+ cursor: pointer;
119
+ transition: all 0.2s ease;
120
+ }
121
+
122
+ .optionBtn:hover {
123
+ background: rgba(255, 255, 255, 0.15);
124
+ border-color: rgba(255, 255, 255, 0.3);
125
+ }
126
+
127
+ .noneBtn {
128
+ border-color: rgba(255, 77, 79, 0.3);
129
+ color: #ff4d4f;
130
+ }
131
+
132
+ .noneBtn:hover {
133
+ background: rgba(255, 77, 79, 0.1);
134
+ border-color: rgba(255, 77, 79, 0.5);
135
+ }
136
+
137
+ .cancelBtn {
138
+ background: none;
139
+ border: none;
140
+ color: rgba(255, 255, 255, 0.5);
141
+ font-size: 0.85rem;
142
+ text-decoration: underline;
143
+ cursor: pointer;
144
+ }
145
+
146
+ .cancelBtn:hover {
147
+ color: rgba(255, 255, 255, 0.8);
148
+ }
149
+
150
+ @keyframes fadeIn {
151
+ from { opacity: 0; transform: translateY(5px); }
152
+ to { opacity: 1; transform: translateY(0); }
153
+ }
154
+
155
+ @keyframes slideDown {
156
+ from { opacity: 0; transform: translateY(-10px); }
157
+ to { opacity: 1; transform: translateY(0); }
158
+ }
frontend/src/components/FontScaler.js CHANGED
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react';
4
  import styles from './FontScaler.module.css';
5
 
6
  export default function FontScaler() {
7
- // Start with 1.2 since the user requested a 20% base increase
8
  const [scale, setScale] = useState(1.2);
9
 
10
  useEffect(() => {
 
4
  import styles from './FontScaler.module.css';
5
 
6
  export default function FontScaler() {
 
7
  const [scale, setScale] = useState(1.2);
8
 
9
  useEffect(() => {
frontend/src/components/ParticlesBg.js CHANGED
@@ -5,9 +5,8 @@ import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
5
  const ParticlesBg = forwardRef((props, ref) => {
6
  const canvasRef = useRef(null);
7
 
8
- // Base color matches --accent-primary from globals.css
9
- let targetColor = { r: 255, g: 61, b: 61 };
10
- let currentColor = { r: 255, g: 61, b: 61 };
11
 
12
  const hexToRgb = (hex) => {
13
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
@@ -15,15 +14,15 @@ const ParticlesBg = forwardRef((props, ref) => {
15
  r: parseInt(result[1], 16),
16
  g: parseInt(result[2], 16),
17
  b: parseInt(result[3], 16)
18
- } : { r: 255, g: 61, b: 61 };
19
  };
20
 
21
  useImperativeHandle(ref, () => ({
22
  setAccentColor: (hex) => {
23
  if (hex) {
24
- targetColor = hexToRgb(hex);
25
  } else {
26
- targetColor = { r: 255, g: 61, b: 61 };
27
  }
28
  }
29
  }));
@@ -37,7 +36,6 @@ const ParticlesBg = forwardRef((props, ref) => {
37
  let particles = [];
38
  let connectionDist = 120;
39
 
40
- // Desktop baseline: 1920*1080 = ~2M pixels → 70 particles
41
  const BASELINE_AREA = 1920 * 1080;
42
  const BASELINE_PARTICLES = 70;
43
  const BASELINE_DIAGONAL = Math.sqrt(1920 * 1920 + 1080 * 1080);
@@ -51,11 +49,9 @@ const ParticlesBg = forwardRef((props, ref) => {
51
  const area = canvas.width * canvas.height;
52
  const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2);
53
 
54
- // Scale particles proportionally to the screen area ratio
55
  const areaRatio = area / BASELINE_AREA;
56
  const targetCount = Math.max(MIN_PARTICLES, Math.round(BASELINE_PARTICLES * areaRatio));
57
 
58
- // Scale connection distance proportionally to the diagonal ratio
59
  connectionDist = Math.round(BASELINE_CONN_DIST * (diagonal / BASELINE_DIAGONAL));
60
 
61
  if (particles.length > targetCount) {
@@ -94,16 +90,15 @@ const ParticlesBg = forwardRef((props, ref) => {
94
  resize();
95
 
96
  const animate = () => {
97
- // Smooth color transition (linear interpolation)
98
- currentColor.r += (targetColor.r - currentColor.r) * 0.05;
99
- currentColor.g += (targetColor.g - currentColor.g) * 0.05;
100
- currentColor.b += (targetColor.b - currentColor.b) * 0.05;
101
 
102
  ctx.clearRect(0, 0, canvas.width, canvas.height);
103
 
104
  particles.forEach(p => {
105
  p.update();
106
- p.draw(ctx, currentColor);
107
  });
108
 
109
  for (let i = 0; i < particles.length; i++) {
@@ -116,7 +111,7 @@ const ParticlesBg = forwardRef((props, ref) => {
116
  ctx.beginPath();
117
  ctx.moveTo(particles[i].x, particles[i].y);
118
  ctx.lineTo(particles[j].x, particles[j].y);
119
- ctx.strokeStyle = `rgba(${Math.floor(currentColor.r)}, ${Math.floor(currentColor.g)}, ${Math.floor(currentColor.b)}, ${(1 - dist / connectionDist) * 0.4})`;
120
  ctx.lineWidth = 1;
121
  ctx.stroke();
122
  }
 
5
  const ParticlesBg = forwardRef((props, ref) => {
6
  const canvasRef = useRef(null);
7
 
8
+ const targetColorRef = useRef({ r: 56, g: 189, b: 248 });
9
+ const currentColorRef = useRef({ r: 56, g: 189, b: 248 });
 
10
 
11
  const hexToRgb = (hex) => {
12
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
 
14
  r: parseInt(result[1], 16),
15
  g: parseInt(result[2], 16),
16
  b: parseInt(result[3], 16)
17
+ } : { r: 56, g: 189, b: 248 };
18
  };
19
 
20
  useImperativeHandle(ref, () => ({
21
  setAccentColor: (hex) => {
22
  if (hex) {
23
+ targetColorRef.current = hexToRgb(hex);
24
  } else {
25
+ targetColorRef.current = { r: 56, g: 189, b: 248 };
26
  }
27
  }
28
  }));
 
36
  let particles = [];
37
  let connectionDist = 120;
38
 
 
39
  const BASELINE_AREA = 1920 * 1080;
40
  const BASELINE_PARTICLES = 70;
41
  const BASELINE_DIAGONAL = Math.sqrt(1920 * 1920 + 1080 * 1080);
 
49
  const area = canvas.width * canvas.height;
50
  const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2);
51
 
 
52
  const areaRatio = area / BASELINE_AREA;
53
  const targetCount = Math.max(MIN_PARTICLES, Math.round(BASELINE_PARTICLES * areaRatio));
54
 
 
55
  connectionDist = Math.round(BASELINE_CONN_DIST * (diagonal / BASELINE_DIAGONAL));
56
 
57
  if (particles.length > targetCount) {
 
90
  resize();
91
 
92
  const animate = () => {
93
+ currentColorRef.current.r += (targetColorRef.current.r - currentColorRef.current.r) * 0.05;
94
+ currentColorRef.current.g += (targetColorRef.current.g - currentColorRef.current.g) * 0.05;
95
+ currentColorRef.current.b += (targetColorRef.current.b - currentColorRef.current.b) * 0.05;
 
96
 
97
  ctx.clearRect(0, 0, canvas.width, canvas.height);
98
 
99
  particles.forEach(p => {
100
  p.update();
101
+ p.draw(ctx, currentColorRef.current);
102
  });
103
 
104
  for (let i = 0; i < particles.length; i++) {
 
111
  ctx.beginPath();
112
  ctx.moveTo(particles[i].x, particles[i].y);
113
  ctx.lineTo(particles[j].x, particles[j].y);
114
+ ctx.strokeStyle = `rgba(${Math.floor(currentColorRef.current.r)}, ${Math.floor(currentColorRef.current.g)}, ${Math.floor(currentColorRef.current.b)}, ${(1 - dist / connectionDist) * 0.4})`;
115
  ctx.lineWidth = 1;
116
  ctx.stroke();
117
  }
frontend/src/components/PokedexNetDiagram.js CHANGED
@@ -16,7 +16,6 @@ export default function PokedexNetDiagram() {
16
 
17
  <div className={styles.pipeline}>
18
 
19
- {/* INPUT */}
20
  <div className={`${styles.layer} ${styles.lInput}`} style={{ animationDelay: '0.04s' }}>
21
  <div className={styles.layerName}>Input Tensor</div>
22
  <div className={styles.layerDim}>128 × 128 × 1 &mdash; grayscale</div>
@@ -26,7 +25,6 @@ export default function PokedexNetDiagram() {
26
  <div className={styles.connLine}></div>
27
  </div>
28
 
29
- {/* CONV1 (adapted: in_channels=1) */}
30
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.08s' }}>
31
  <div className={styles.layerName}>Conv1 &mdash; adaptado</div>
32
  <div className={styles.layerDim}>in=1 &nbsp;|&nbsp; 64 filtros &nbsp;|&nbsp; 7×7 kernel &nbsp;|&nbsp; stride 2 &nbsp;|&nbsp; pad 3 &nbsp;|&nbsp; bias=False</div>
@@ -36,7 +34,6 @@ export default function PokedexNetDiagram() {
36
  <div className={styles.connLine}></div>
37
  </div>
38
 
39
- {/* BN + ReLU + MaxPool */}
40
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.12s' }}>
41
  <div className={styles.layerName}>BN → ReLU → MaxPool</div>
42
  <div className={styles.layerDim}>BatchNorm2d(64) &nbsp;|&nbsp; ReLU &nbsp;|&nbsp; MaxPool 3×3 stride 2 pad 1</div>
@@ -46,7 +43,6 @@ export default function PokedexNetDiagram() {
46
  <div className={styles.connLine}></div>
47
  </div>
48
 
49
- {/* LAYER 1: 2× BasicBlock(64, 64) — no downsampling */}
50
  <div className={styles.resnetLayer} style={{ animationDelay: '0.17s' }}>
51
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.16s' }}>layer1 &mdash; 2× basicblock</div>
52
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.17s' }}>
@@ -60,15 +56,12 @@ export default function PokedexNetDiagram() {
60
  <div className={styles.layerName}>BasicBlock [2/2]</div>
61
  <div className={styles.layerDim}>Conv3×3(64→64) → BN → ReLU → Conv3×3(64→64) → BN &nbsp;|&nbsp; stride 1</div>
62
  </div>
63
- {/* skip arrows: one per block */}
64
  <div className={styles.skipSvg}>
65
  <svg viewBox="0 0 36 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
66
- {/* block 1 skip: top 0%..48% */}
67
  <path d="M4,4 Q28,4 28,25 Q28,46 4,46"
68
  fill="none" stroke="#c084fc" strokeWidth="1.4"
69
  strokeDasharray="4 3" opacity="0.75"/>
70
  <polygon points="4,41 0,47 8,47" fill="#c084fc" opacity="0.8"/>
71
- {/* block 2 skip: 52%..100% */}
72
  <path d="M4,54 Q28,54 28,75 Q28,96 4,96"
73
  fill="none" stroke="#c084fc" strokeWidth="1.4"
74
  strokeDasharray="4 3" opacity="0.75"/>
@@ -81,7 +74,6 @@ export default function PokedexNetDiagram() {
81
  <div className={styles.connLine}></div>
82
  </div>
83
 
84
- {/* LAYER 2: 2× BasicBlock(64→128) — downsample stride 2 */}
85
  <div className={styles.resnetLayer} style={{ animationDelay: '0.23s' }}>
86
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.22s' }}>layer2 &mdash; 2× basicblock &nbsp;(downsample)</div>
87
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.23s' }}>
@@ -113,7 +105,6 @@ export default function PokedexNetDiagram() {
113
  <div className={styles.connLine}></div>
114
  </div>
115
 
116
- {/* LAYER 3: 2× BasicBlock(128→256) — downsample */}
117
  <div className={styles.resnetLayer} style={{ animationDelay: '0.29s' }}>
118
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.28s' }}>layer3 &mdash; 2× basicblock &nbsp;(downsample)</div>
119
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.29s' }}>
@@ -145,7 +136,6 @@ export default function PokedexNetDiagram() {
145
  <div className={styles.connLine}></div>
146
  </div>
147
 
148
- {/* LAYER 4: 2× BasicBlock(256→512) — downsample */}
149
  <div className={styles.resnetLayer} style={{ animationDelay: '0.35s' }}>
150
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.34s' }}>layer4 &mdash; 2× basicblock &nbsp;(downsample)</div>
151
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.35s' }}>
@@ -177,7 +167,6 @@ export default function PokedexNetDiagram() {
177
  <div className={styles.connLine}></div>
178
  </div>
179
 
180
- {/* GLOBAL AVG POOL */}
181
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.40s' }}>
182
  <div className={styles.layerName}>Global Average Pool</div>
183
  <div className={styles.layerDim}>AdaptiveAvgPool2d(1×1) &nbsp;→&nbsp; 1 × 1 × 512</div>
@@ -187,7 +176,6 @@ export default function PokedexNetDiagram() {
187
  <div className={styles.connLine}></div>
188
  </div>
189
 
190
- {/* FLATTEN */}
191
  <div className={`${styles.layer} ${styles.lFlatten}`} style={{ animationDelay: '0.43s' }}>
192
  <div className={styles.layerName}>Flatten</div>
193
  <div className={styles.layerDim}>512-dim feature vector</div>
@@ -197,13 +185,12 @@ export default function PokedexNetDiagram() {
197
  <div className={styles.connLine}></div>
198
  </div>
199
 
200
- {/* FC OUTPUT */}
201
  <div className={`${styles.layer} ${styles.lOutput}`} style={{ animationDelay: '0.46s' }}>
202
  <div className={styles.layerName}>FC — backbone.fc</div>
203
  <div className={styles.layerDim}>Linear(512 → 1025) &nbsp;|&nbsp; Softmax</div>
204
  </div>
205
 
206
- </div>{/* /pipeline */}
207
 
208
  <div className={styles.legend}>
209
  <div className={styles.legendItem}>
 
16
 
17
  <div className={styles.pipeline}>
18
 
 
19
  <div className={`${styles.layer} ${styles.lInput}`} style={{ animationDelay: '0.04s' }}>
20
  <div className={styles.layerName}>Input Tensor</div>
21
  <div className={styles.layerDim}>128 × 128 × 1 &mdash; grayscale</div>
 
25
  <div className={styles.connLine}></div>
26
  </div>
27
 
 
28
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.08s' }}>
29
  <div className={styles.layerName}>Conv1 &mdash; adaptado</div>
30
  <div className={styles.layerDim}>in=1 &nbsp;|&nbsp; 64 filtros &nbsp;|&nbsp; 7×7 kernel &nbsp;|&nbsp; stride 2 &nbsp;|&nbsp; pad 3 &nbsp;|&nbsp; bias=False</div>
 
34
  <div className={styles.connLine}></div>
35
  </div>
36
 
 
37
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.12s' }}>
38
  <div className={styles.layerName}>BN → ReLU → MaxPool</div>
39
  <div className={styles.layerDim}>BatchNorm2d(64) &nbsp;|&nbsp; ReLU &nbsp;|&nbsp; MaxPool 3×3 stride 2 pad 1</div>
 
43
  <div className={styles.connLine}></div>
44
  </div>
45
 
 
46
  <div className={styles.resnetLayer} style={{ animationDelay: '0.17s' }}>
47
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.16s' }}>layer1 &mdash; 2× basicblock</div>
48
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.17s' }}>
 
56
  <div className={styles.layerName}>BasicBlock [2/2]</div>
57
  <div className={styles.layerDim}>Conv3×3(64→64) → BN → ReLU → Conv3×3(64→64) → BN &nbsp;|&nbsp; stride 1</div>
58
  </div>
 
59
  <div className={styles.skipSvg}>
60
  <svg viewBox="0 0 36 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
 
61
  <path d="M4,4 Q28,4 28,25 Q28,46 4,46"
62
  fill="none" stroke="#c084fc" strokeWidth="1.4"
63
  strokeDasharray="4 3" opacity="0.75"/>
64
  <polygon points="4,41 0,47 8,47" fill="#c084fc" opacity="0.8"/>
 
65
  <path d="M4,54 Q28,54 28,75 Q28,96 4,96"
66
  fill="none" stroke="#c084fc" strokeWidth="1.4"
67
  strokeDasharray="4 3" opacity="0.75"/>
 
74
  <div className={styles.connLine}></div>
75
  </div>
76
 
 
77
  <div className={styles.resnetLayer} style={{ animationDelay: '0.23s' }}>
78
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.22s' }}>layer2 &mdash; 2× basicblock &nbsp;(downsample)</div>
79
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.23s' }}>
 
105
  <div className={styles.connLine}></div>
106
  </div>
107
 
 
108
  <div className={styles.resnetLayer} style={{ animationDelay: '0.29s' }}>
109
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.28s' }}>layer3 &mdash; 2× basicblock &nbsp;(downsample)</div>
110
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.29s' }}>
 
136
  <div className={styles.connLine}></div>
137
  </div>
138
 
 
139
  <div className={styles.resnetLayer} style={{ animationDelay: '0.35s' }}>
140
  <div className={styles.lGroupLabel} style={{ animationDelay: '0.34s' }}>layer4 &mdash; 2× basicblock &nbsp;(downsample)</div>
141
  <div className={`${styles.layer} ${styles.lBlock}`} style={{ width: '100%', animationDelay: '0.35s' }}>
 
167
  <div className={styles.connLine}></div>
168
  </div>
169
 
 
170
  <div className={`${styles.layer} ${styles.lStruct}`} style={{ animationDelay: '0.40s' }}>
171
  <div className={styles.layerName}>Global Average Pool</div>
172
  <div className={styles.layerDim}>AdaptiveAvgPool2d(1×1) &nbsp;→&nbsp; 1 × 1 × 512</div>
 
176
  <div className={styles.connLine}></div>
177
  </div>
178
 
 
179
  <div className={`${styles.layer} ${styles.lFlatten}`} style={{ animationDelay: '0.43s' }}>
180
  <div className={styles.layerName}>Flatten</div>
181
  <div className={styles.layerDim}>512-dim feature vector</div>
 
185
  <div className={styles.connLine}></div>
186
  </div>
187
 
 
188
  <div className={`${styles.layer} ${styles.lOutput}`} style={{ animationDelay: '0.46s' }}>
189
  <div className={styles.layerName}>FC — backbone.fc</div>
190
  <div className={styles.layerDim}>Linear(512 → 1025) &nbsp;|&nbsp; Softmax</div>
191
  </div>
192
 
193
+ </div>
194
 
195
  <div className={styles.legend}>
196
  <div className={styles.legendItem}>
frontend/src/components/ResultCard.js CHANGED
@@ -1,17 +1,25 @@
1
  'use client';
 
 
2
  import styles from './ResultCard.module.css';
3
  import TypeBadge from './TypeBadge';
4
  import ConfidenceBar from './ConfidenceBar';
5
  import StatsRadar from './StatsRadar';
6
  import TopPredictions from './TopPredictions';
 
 
 
 
7
 
8
- export default function ResultCard({ predictionData, pokemonDetails, onReset }) {
9
  if (!predictionData) return null;
10
 
11
  const { pokemon_id, name, confidence, top_5 } = predictionData;
12
  const types = pokemonDetails?.types || [];
13
  const stats = pokemonDetails?.stats || {};
14
- const spriteUrl = pokemonDetails?.sprite_url;
 
 
 
15
 
16
  return (
17
  <div className={`glass-panel ${styles.card}`}>
@@ -23,12 +31,25 @@ export default function ResultCard({ predictionData, pokemonDetails, onReset })
23
  <button className={styles.closeBtn} onClick={onReset} aria-label="Try another">✕</button>
24
  </div>
25
 
 
 
 
 
 
 
 
 
 
 
26
  <div className={styles.content}>
27
  <div className={styles.visualColumn}>
28
  <div className={`${styles.spriteContainer} ${styles.stagger1}`}>
29
  {spriteUrl ? (
30
- // eslint-disable-next-line @next/next/no-img-element
31
- <img src={spriteUrl} alt={name} className={styles.sprite} />
 
 
 
32
  ) : (
33
  <div className={styles.noSprite}>?</div>
34
  )}
@@ -40,6 +61,24 @@ export default function ResultCard({ predictionData, pokemonDetails, onReset })
40
  {types.map(type => (
41
  <TypeBadge key={type} type={type} />
42
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  </div>
44
 
45
  <div className={styles.stagger2}>
@@ -54,6 +93,56 @@ export default function ResultCard({ predictionData, pokemonDetails, onReset })
54
  <div className={styles.stagger4}>
55
  <TopPredictions predictions={top_5} />
56
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
58
  </div>
59
  </div>
 
1
  'use client';
2
+ import { useState } from 'react';
3
+ import confetti from 'canvas-confetti';
4
  import styles from './ResultCard.module.css';
5
  import TypeBadge from './TypeBadge';
6
  import ConfidenceBar from './ConfidenceBar';
7
  import StatsRadar from './StatsRadar';
8
  import TopPredictions from './TopPredictions';
9
+ import FeedbackWidget from './FeedbackWidget';
10
+
11
+ export default function ResultCard({ predictionData, pokemonDetails, onReset, onShinyActivated, onDislikeActivated }) {
12
+ const [showShiny, setShowShiny] = useState(false);
13
 
 
14
  if (!predictionData) return null;
15
 
16
  const { pokemon_id, name, confidence, top_5 } = predictionData;
17
  const types = pokemonDetails?.types || [];
18
  const stats = pokemonDetails?.stats || {};
19
+
20
+ const spriteUrl = showShiny
21
+ ? `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/shiny/${pokemon_id}.png`
22
+ : pokemonDetails?.sprite_url;
23
 
24
  return (
25
  <div className={`glass-panel ${styles.card}`}>
 
31
  <button className={styles.closeBtn} onClick={onReset} aria-label="Try another">✕</button>
32
  </div>
33
 
34
+ {predictionData.status === 'UNCERTAIN_POKEMON' && (
35
+ <div style={{ backgroundColor: 'rgba(255, 165, 0, 0.1)', border: '1px solid rgba(255, 165, 0, 0.3)', borderLeft: '4px solid orange', padding: '1rem', margin: '0 2rem 1.5rem 2rem', borderRadius: '8px', display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
36
+ <span style={{ fontSize: '1.5rem', lineHeight: '1' }}>⚠️</span>
37
+ <span style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: '0.95rem', lineHeight: '1.4' }}>
38
+ <strong style={{ color: 'orange', display: 'block', marginBottom: '0.25rem' }}>Uncertainty Warning</strong>
39
+ Unfortunately, PokedexNet cannot guarantee this prediction. While this is our best guess, the silhouette is heavily occluded, noisy, or atypical, triggering an epistemic disagreement in our ensemble.
40
+ </span>
41
+ </div>
42
+ )}
43
+
44
  <div className={styles.content}>
45
  <div className={styles.visualColumn}>
46
  <div className={`${styles.spriteContainer} ${styles.stagger1}`}>
47
  {spriteUrl ? (
48
+ <img
49
+ src={spriteUrl}
50
+ alt={name}
51
+ className={styles.sprite}
52
+ />
53
  ) : (
54
  <div className={styles.noSprite}>?</div>
55
  )}
 
61
  {types.map(type => (
62
  <TypeBadge key={type} type={type} />
63
  ))}
64
+ {showShiny && (
65
+ <span style={{
66
+ display: 'inline-block',
67
+ padding: '4px 16px',
68
+ borderRadius: '20px',
69
+ fontSize: '0.85rem',
70
+ fontWeight: '700',
71
+ letterSpacing: '1px',
72
+ marginRight: '8px',
73
+ marginBottom: '8px',
74
+ backdropFilter: 'blur(4px)',
75
+ backgroundColor: 'color-mix(in srgb, #FFD700 20%, transparent)',
76
+ color: '#FFD700',
77
+ border: '1px solid color-mix(in srgb, #FFD700 50%, transparent)'
78
+ }}>
79
+ SHINY
80
+ </span>
81
+ )}
82
  </div>
83
 
84
  <div className={styles.stagger2}>
 
93
  <div className={styles.stagger4}>
94
  <TopPredictions predictions={top_5} />
95
  </div>
96
+
97
+ <div className={styles.stagger4}>
98
+ <FeedbackWidget
99
+ imageHash={predictionData.image_hash}
100
+ predictedId={pokemon_id}
101
+ top5={top_5}
102
+ onFeedbackSuccess={(isCorrect) => {
103
+ if (isCorrect) {
104
+ setShowShiny(true);
105
+ if (onShinyActivated) {
106
+ onShinyActivated();
107
+ }
108
+
109
+ const defaults = {
110
+ spread: 360,
111
+ ticks: 50,
112
+ gravity: 0,
113
+ decay: 0.94,
114
+ startVelocity: 24,
115
+ shapes: ['star'],
116
+ colors: ['FFE400', 'FFBD00', 'E89400', 'FFCA6C', 'FDFFB8']
117
+ };
118
+
119
+ function shoot() {
120
+ confetti({
121
+ ...defaults,
122
+ particleCount: 30,
123
+ scalar: 1.1,
124
+ shapes: ['star']
125
+ });
126
+
127
+ confetti({
128
+ ...defaults,
129
+ particleCount: 8,
130
+ scalar: 0.6,
131
+ shapes: ['circle']
132
+ });
133
+ }
134
+
135
+ setTimeout(shoot, 0);
136
+ setTimeout(shoot, 100);
137
+ setTimeout(shoot, 200);
138
+ } else {
139
+ if (onDislikeActivated) {
140
+ onDislikeActivated();
141
+ }
142
+ }
143
+ }}
144
+ />
145
+ </div>
146
  </div>
147
  </div>
148
  </div>
frontend/src/components/StatsRadar.js CHANGED
@@ -21,7 +21,6 @@ export default function StatsRadar({ stats }) {
21
 
22
  ctx.clearRect(0, 0, width, height);
23
 
24
- // Draw background grid
25
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
26
  ctx.lineWidth = 1;
27
  for (let level = 1; level <= 3; level++) {
@@ -38,7 +37,6 @@ export default function StatsRadar({ stats }) {
38
  ctx.stroke();
39
  }
40
 
41
- // Draw axes and labels
42
  ctx.font = '10px monospace';
43
  ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
44
  ctx.textAlign = 'center';
@@ -59,7 +57,6 @@ export default function StatsRadar({ stats }) {
59
  ctx.fillText(labels[i], labelX, labelY);
60
  }
61
 
62
- // Draw data polygon
63
  ctx.beginPath();
64
  for (let i = 0; i < 6; i++) {
65
  const statVal = stats[statKeys[i]] || 0;
 
21
 
22
  ctx.clearRect(0, 0, width, height);
23
 
 
24
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
25
  ctx.lineWidth = 1;
26
  for (let level = 1; level <= 3; level++) {
 
37
  ctx.stroke();
38
  }
39
 
 
40
  ctx.font = '10px monospace';
41
  ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
42
  ctx.textAlign = 'center';
 
57
  ctx.fillText(labels[i], labelX, labelY);
58
  }
59
 
 
60
  ctx.beginPath();
61
  for (let i = 0; i < 6; i++) {
62
  const statVal = stats[statKeys[i]] || 0;
frontend/src/components/TopPredictions.js CHANGED
@@ -4,7 +4,6 @@ import styles from './TopPredictions.module.css';
4
  export default function TopPredictions({ predictions }) {
5
  if (!predictions || predictions.length <= 1) return null;
6
 
7
- // Skip the top 1 because it is displayed as the primary result
8
  const runnersUp = predictions.slice(1, 5);
9
 
10
  return (
 
4
  export default function TopPredictions({ predictions }) {
5
  if (!predictions || predictions.length <= 1) return null;
6
 
 
7
  const runnersUp = predictions.slice(1, 5);
8
 
9
  return (
frontend/src/components/TypeBadge.js CHANGED
@@ -2,7 +2,6 @@
2
  import styles from './TypeBadge.module.css';
3
 
4
  export default function TypeBadge({ type }) {
5
- // Use CSS custom properties defined in globals.css
6
  const colorVar = `var(--type-${type.toLowerCase()})`;
7
 
8
  return (
 
2
  import styles from './TypeBadge.module.css';
3
 
4
  export default function TypeBadge({ type }) {
 
5
  const colorVar = `var(--type-${type.toLowerCase()})`;
6
 
7
  return (
frontend/src/components/UploadZone.js CHANGED
@@ -3,7 +3,6 @@
3
  import { useState, useRef } from 'react';
4
  import styles from './UploadZone.module.css';
5
 
6
- // Client-side image resizer
7
  const resizeImage = (file, maxSize = 512) => {
8
  return new Promise((resolve) => {
9
  const img = new Image();
@@ -55,7 +54,6 @@ export default function UploadZone({ status, selectedImage, onImageSelect, onSub
55
  const handleDragLeave = (e) => {
56
  e.preventDefault();
57
  e.stopPropagation();
58
- // Only set dragging to false if we actually leave the container (not to child elements)
59
  if (!e.currentTarget.contains(e.relatedTarget)) {
60
  setIsDragging(false);
61
  }
@@ -64,14 +62,12 @@ export default function UploadZone({ status, selectedImage, onImageSelect, onSub
64
  const processFile = async (file) => {
65
  if (!file) return;
66
 
67
- // Validate MIME
68
  const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
69
  if (!allowed.includes(file.type)) {
70
  onImageSelect(null, "Invalid file type. Use PNG, JPEG, WebP, or GIF.");
71
  return;
72
  }
73
 
74
- // Validate raw size
75
  if (file.size > 10 * 1024 * 1024) {
76
  onImageSelect(null, "File is too large. Max 10MB allowed.");
77
  return;
@@ -112,7 +108,7 @@ export default function UploadZone({ status, selectedImage, onImageSelect, onSub
112
  };
113
 
114
  if (status === 'loading' || status === 'success') {
115
- return null; // Hide upload zone when loading or viewing result
116
  }
117
 
118
  return (
 
3
  import { useState, useRef } from 'react';
4
  import styles from './UploadZone.module.css';
5
 
 
6
  const resizeImage = (file, maxSize = 512) => {
7
  return new Promise((resolve) => {
8
  const img = new Image();
 
54
  const handleDragLeave = (e) => {
55
  e.preventDefault();
56
  e.stopPropagation();
 
57
  if (!e.currentTarget.contains(e.relatedTarget)) {
58
  setIsDragging(false);
59
  }
 
62
  const processFile = async (file) => {
63
  if (!file) return;
64
 
 
65
  const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
66
  if (!allowed.includes(file.type)) {
67
  onImageSelect(null, "Invalid file type. Use PNG, JPEG, WebP, or GIF.");
68
  return;
69
  }
70
 
 
71
  if (file.size > 10 * 1024 * 1024) {
72
  onImageSelect(null, "File is too large. Max 10MB allowed.");
73
  return;
 
108
  };
109
 
110
  if (status === 'loading' || status === 'success') {
111
+ return null;
112
  }
113
 
114
  return (