Spaces:
Sleeping
Sleeping
feat: implement anomaly detection ensemble, feedback system, and upgraded model configuration
Browse files- README.md +5 -3
- backend/app.py +42 -26
- backend/{pokedex_model.pth → best_model_multirun_seed_25.pth} +0 -0
- backend/best_model_multirun_seed_3.pth +3 -0
- backend/best_model_multirun_seed_7.pth +3 -0
- backend/config.py +13 -8
- backend/feedback_router.py +157 -0
- backend/model.py +2 -0
- backend/pokemon_service.py +2 -11
- backend/predict.py +71 -13
- backend/rate_limiter.py +0 -4
- backend/requirements.txt +1 -0
- frontend/package.json +1 -1
- frontend/src/app/globals.css +27 -0
- frontend/src/app/page.js +64 -5
- frontend/src/app/research/page.js +115 -8
- frontend/src/app/research/page.module.css +77 -0
- frontend/src/components/ConfidenceBar.js +0 -1
- frontend/src/components/FeedbackWidget.js +112 -0
- frontend/src/components/FeedbackWidget.module.css +158 -0
- frontend/src/components/FontScaler.js +0 -1
- frontend/src/components/ParticlesBg.js +10 -15
- frontend/src/components/PokedexNetDiagram.js +1 -14
- frontend/src/components/ResultCard.js +93 -4
- frontend/src/components/StatsRadar.js +0 -3
- frontend/src/components/TopPredictions.js +0 -1
- frontend/src/components/TypeBadge.js +0 -1
- frontend/src/components/UploadZone.js +1 -5
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.
|
| 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-
|
| 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(
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
else:
|
| 40 |
-
app.state.
|
| 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
|
| 72 |
-
|
| 73 |
-
if not
|
| 74 |
-
raise HTTPException(status_code=503, detail="
|
| 75 |
-
return
|
| 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 |
-
|
| 90 |
device_obj = getattr(request.app.state, "device", None)
|
| 91 |
|
| 92 |
return {
|
| 93 |
"status": "healthy",
|
| 94 |
-
"
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)))
|
| 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.
|
| 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()
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
-
top_probs, top_indices = torch.topk(
|
| 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"]),
|
| 284 |
name=str(best_pred["name"]),
|
| 285 |
-
confidence=float(best_pred["confidence"]),
|
| 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": "
|
| 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');
|
| 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 === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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'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
|
| 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
|
| 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
|
| 289 |
</p>
|
| 290 |
<p className={styles.paragraph}>
|
| 291 |
-
The seed-dependent instability of the top prediction is itself
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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'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'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> =
|
| 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 |
+
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> > 9.0</span>), whereas abstract shapes and real-world photographs yield significantly weaker signals. Enforcing a threshold of <span className="mono">μ<sub>logit</sub> < 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'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> = P(y<sub>1, i</sub>) − 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> > 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> < 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'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'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 |
-
|
| 9 |
-
|
| 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:
|
| 19 |
};
|
| 20 |
|
| 21 |
useImperativeHandle(ref, () => ({
|
| 22 |
setAccentColor: (hex) => {
|
| 23 |
if (hex) {
|
| 24 |
-
|
| 25 |
} else {
|
| 26 |
-
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 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,
|
| 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(
|
| 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 — 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 — adaptado</div>
|
| 32 |
<div className={styles.layerDim}>in=1 | 64 filtros | 7×7 kernel | stride 2 | pad 3 | 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) | ReLU | 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 — 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 | 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 — 2× basicblock (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 — 2× basicblock (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 — 2× basicblock (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) → 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) | Softmax</div>
|
| 204 |
</div>
|
| 205 |
|
| 206 |
-
</div>
|
| 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 — 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 — adaptado</div>
|
| 30 |
<div className={styles.layerDim}>in=1 | 64 filtros | 7×7 kernel | stride 2 | pad 3 | 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) | ReLU | 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 — 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 | 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 — 2× basicblock (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 — 2× basicblock (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 — 2× basicblock (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) → 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) | 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 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 (
|