gpimentel commited on
Commit
f2bfbb5
·
0 Parent(s):

feat: initialize Pokedex Web project with FastAPI backend and React frontend

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +39 -0
  2. .gitattributes +3 -0
  3. .gitignore +52 -0
  4. Dockerfile +62 -0
  5. README.md +152 -0
  6. backend/app.py +208 -0
  7. backend/config.py +55 -0
  8. backend/model.py +13 -0
  9. backend/poke_database.json +0 -0
  10. backend/pokedex_model.pth +3 -0
  11. backend/pokemon_service.py +144 -0
  12. backend/predict.py +289 -0
  13. backend/rate_limiter.py +46 -0
  14. backend/requirements.txt +9 -0
  15. frontend/eslint.config.mjs +16 -0
  16. frontend/jsconfig.json +7 -0
  17. frontend/next.config.mjs +9 -0
  18. frontend/package-lock.json +0 -0
  19. frontend/package.json +21 -0
  20. frontend/public/file.svg +1 -0
  21. frontend/public/globe.svg +1 -0
  22. frontend/public/next.svg +1 -0
  23. frontend/public/static/images/1.png +3 -0
  24. frontend/public/static/images/augmentation_visual.png +3 -0
  25. frontend/public/static/images/digimon.png +3 -0
  26. frontend/public/static/images/mosaic_confusion_extreme_cases.png +3 -0
  27. frontend/public/static/images/pipeline_visual.png +3 -0
  28. frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_121.pth.png +3 -0
  29. frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_25.pth.png +3 -0
  30. frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_255.pth.png +3 -0
  31. frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_3.pth.png +3 -0
  32. frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_7.pth.png +3 -0
  33. frontend/public/static/images/training_curves_multirun_seed_121.png +3 -0
  34. frontend/public/static/images/training_curves_multirun_seed_25.png +3 -0
  35. frontend/public/static/images/training_curves_multirun_seed_255.png +3 -0
  36. frontend/public/static/images/training_curves_multirun_seed_3.png +3 -0
  37. frontend/public/static/images/training_curves_multirun_seed_7.png +3 -0
  38. frontend/public/vercel.svg +1 -0
  39. frontend/public/window.svg +1 -0
  40. frontend/src/app/favicon.ico +0 -0
  41. frontend/src/app/globals.css +182 -0
  42. frontend/src/app/layout.js +14 -0
  43. frontend/src/app/page.js +135 -0
  44. frontend/src/app/page.module.css +142 -0
  45. frontend/src/app/research/page.js +355 -0
  46. frontend/src/app/research/page.module.css +355 -0
  47. frontend/src/components/ConfidenceBar.js +34 -0
  48. frontend/src/components/ConfidenceBar.module.css +55 -0
  49. frontend/src/components/FontScaler.js +24 -0
  50. frontend/src/components/FontScaler.module.css +34 -0
.dockerignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+ .github
5
+
6
+ # Node / Next.js
7
+ frontend/node_modules/
8
+ frontend/.next/
9
+ frontend/out/
10
+ frontend/.env.local
11
+ frontend/.env.development.local
12
+ frontend/.env.test.local
13
+ frontend/.env.production.local
14
+
15
+ # Python
16
+ **/__pycache__/
17
+ **/*.pyc
18
+ **/*.pyo
19
+ **/*.pyd
20
+ **/.Python
21
+ **/.env
22
+ **/.venv
23
+ **/env/
24
+ **/venv/
25
+ backend/env/
26
+
27
+ # Data and Models
28
+ # Exclude model weights from the build context if they are managed via Git LFS
29
+ # pokedex_model.pth (Actually, keep it if HF clones it natively via LFS, wait! HF Spaces clones the repo first. If we ignore it in .dockerignore, it won't be copied into the container!)
30
+ # Let's NOT ignore pokedex_model.pth or poke_database.json here!
31
+
32
+ # OS generated files
33
+ .DS_Store
34
+ .DS_Store?
35
+ ._*
36
+ .Spotlight-V100
37
+ .Trashes
38
+ ehthumbs.db
39
+ Thumbs.db
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Hugging Face Spaces Git LFS configuration
2
+ *.pth filter=lfs diff=lfs merge=lfs -text
3
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+
26
+ # Environment (Zero Trust)
27
+ .env
28
+ .venv
29
+ venv/
30
+ ENV/
31
+
32
+ # Node/Next.js
33
+ .next/
34
+ out/
35
+ node_modules/
36
+
37
+ # Spec Driven Development (Private)
38
+ docs/
39
+
40
+ # IDEs
41
+ .vscode/
42
+ .idea/
43
+ .DS_Store
44
+
45
+ # Node/Next.js Logs & Misc
46
+ npm-debug.log*
47
+ yarn-debug.log*
48
+ yarn-error.log*
49
+ .pnpm-debug.log*
50
+ .vercel
51
+ *.tsbuildinfo
52
+ next-env.d.ts
Dockerfile ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------------------------------------------------
2
+ # Stage 1: Build the frontend Next.js App
3
+ # ---------------------------------------------------------
4
+ FROM node:20-alpine AS frontend-builder
5
+
6
+ WORKDIR /app
7
+
8
+ # Install dependencies first for layer caching
9
+ COPY frontend/package*.json ./
10
+ RUN npm ci
11
+
12
+ # Copy the rest of the frontend source
13
+ COPY frontend/ ./
14
+
15
+ # Generate the static HTML/CSS/JS export in /app/out
16
+ RUN npm run build
17
+
18
+
19
+ # ---------------------------------------------------------
20
+ # Stage 2: Build the FastAPI Backend
21
+ # ---------------------------------------------------------
22
+ FROM python:3.12-slim
23
+
24
+ # Create a non-root user (UID 1000) as required by Hugging Face Spaces
25
+ RUN useradd -m -u 1000 user
26
+
27
+ WORKDIR /app
28
+
29
+
30
+ # Copy backend requirements first for caching
31
+ COPY --chown=user:user backend/requirements.txt /app/
32
+
33
+ # Install Python dependencies
34
+ # CRITICAL: We force the installation of CPU-only PyTorch wheels.
35
+ # This drastically reduces the image size (from ~2.5GB to ~500MB)
36
+ # and fits nicely into Hugging Face Spaces free-tier constraints.
37
+ RUN pip install --no-cache-dir -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
38
+
39
+ # Copy the rest of the backend files
40
+ COPY --chown=user:user backend/ /app/
41
+
42
+ # Copy the built frontend static files from Stage 1
43
+ # FastAPI mounts this at '/'
44
+ COPY --from=frontend-builder --chown=user:user /app/out /app/static
45
+
46
+ # Ensure the database and current directory are writable by 'user'
47
+ # (The PokemonService cache writes to poke_database.json)
48
+ RUN chown -R user:user /app
49
+
50
+ # Switch to the non-root user
51
+ USER user
52
+
53
+ # Set environment variables
54
+ ENV HOME=/home/user \
55
+ PATH=/home/user/.local/bin:$PATH \
56
+ PYTHONUNBUFFERED=1
57
+
58
+ # Expose the default port used by Hugging Face Spaces
59
+ EXPOSE 7860
60
+
61
+ # Start Uvicorn bound to 0.0.0.0 and port 7860
62
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Pokédex AI — Who's That Pokémon?
3
+ emoji: 🔴
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ ---
10
+
11
+ <div align="center">
12
+ <h1>🔴 Pokédex Web</h1>
13
+ <h3><em>Who's That Pokémon? Solved with Computer Vision.</em></h3>
14
+ </div>
15
+
16
+ <p align="center">
17
+ <strong>A modern, responsive web application that uses a custom-trained ResNet-18 model to identify Pokémon silhouettes from multiple sources (Anime, Monkepo, Geroid, etc.) out of 1,025 different classes.</strong>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#"><img src="https://img.shields.io/badge/version-1.0.0-blueviolet" alt="Latest Version"/></a>
22
+ <a href="#"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"/></a>
23
+ <a href="#"><img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python Version"/></a>
24
+ <a href="#"><img src="https://img.shields.io/badge/next.js-14%2B-black" alt="Next.js Version"/></a>
25
+ <a href="#"><img src="https://img.shields.io/badge/pytorch-2.0%2B-ee4c2c" alt="PyTorch Version"/></a>
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## Table of Contents
31
+
32
+ - [🤔 What is Pokédex Web?](#-what-is-pokedex-web)
33
+ - [⚡ Get Started](#-get-started)
34
+ - [🚀 Features](#-features)
35
+ - [🛠️ Technical Stack](#-technical-stack)
36
+ - [🏗️ Architecture & SDD](#-architecture--sdd)
37
+ - [📄 License](#-license)
38
+
39
+ ## 🤔 What is Pokédex Web?
40
+
41
+ Pokédex Web is an AI-powered application designed to solve the classic "Who's That Pokémon?" challenge. Built with a production-ready monolithic containerized architecture, it pairs a fast Next.js static frontend with a FastAPI Python backend. The system processes images entirely in memory to classify silhouettes using a custom 1-channel grayscale ResNet-18 model.
42
+
43
+ ## ⚡ Get Started
44
+
45
+ ### 1. Prerequisites
46
+
47
+ - [Python 3.12+](https://www.python.org/downloads/)
48
+ - [Node.js 20+](https://nodejs.org/)
49
+ - [uv](https://docs.astral.sh/uv/) (Ultra-fast Python package manager)
50
+ - Docker (Optional, for containerized deployment)
51
+
52
+ ### 2. Local Development (Split Servers)
53
+
54
+ For an optimal development experience, run both servers simultaneously. The Next.js frontend is configured to automatically route API requests to the backend locally.
55
+
56
+ **Terminal 1 (Backend):**
57
+ ```bash
58
+ cd pokedex_web/backend
59
+ uv venv
60
+ source .venv/bin/activate
61
+ uv pip install -r requirements.txt
62
+ uvicorn app:app --reload --port 7860
63
+ ```
64
+
65
+ **Terminal 2 (Frontend):**
66
+ ```bash
67
+ cd pokedex_web/frontend
68
+ npm install
69
+ npm run dev
70
+ # The frontend runs on http://localhost:3000 and auto-connects to the backend at 7860
71
+ ```
72
+
73
+ ### 3. Production Monolith (Local Testing)
74
+
75
+ To test the production-ready Single Page Application (SPA) exactly as it will run on Hugging Face Spaces (everything served from port 7860):
76
+
77
+ ```bash
78
+ # 1. Build the Next.js static export
79
+ cd pokedex_web/frontend
80
+ npm install
81
+ npm run build
82
+
83
+ # 2. Copy the static files to the backend
84
+ rm -rf ../backend/static
85
+ cp -r out ../backend/static
86
+
87
+ # 3. Run FastAPI (serves both REST API and the Frontend)
88
+ cd ../backend
89
+ source .venv/bin/activate
90
+ uvicorn app:app --port 7860
91
+ # Access the complete app at http://localhost:7860
92
+ ```
93
+
94
+ ### 4. Deployment (Docker & Hugging Face Spaces)
95
+
96
+ This project includes a production-ready, multi-stage `Dockerfile` strictly optimized for **Hugging Face Spaces (Free Tier)**. It automatically:
97
+ 1. Builds the Next.js static SPA fallback.
98
+ 2. Creates a secure, non-root user (UID 1000).
99
+ 3. **Optimizes Memory**: Forces the installation of **CPU-only PyTorch wheels**, reducing the image size from ~2.5GB to ~500MB to prevent `OOMKilled` errors on free instances.
100
+
101
+ #### Running Locally via Docker:
102
+ ```bash
103
+ cd pokedex_web
104
+ docker build -t pokedex-web .
105
+ docker run -p 7860:7860 pokedex-web
106
+ # The app will be available at http://localhost:7860
107
+ ```
108
+
109
+ #### Deploying to Hugging Face Spaces:
110
+ 1. Create a new Space on Hugging Face.
111
+ 2. Select **Docker** as the SDK.
112
+ 3. Push this repository to the provided Git remote. The Space will automatically detect the `Dockerfile`, build the optimized container, and serve the application.
113
+
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.
121
+ - **Premium UX/UI (Neon Glassmorphism):** Asymmetrical layouts with an animated liquid aurora mesh background (`radial-gradient` + `filter: blur`), deep iOS/macOS frosted glass blurs, sleek SVG iconography, suspenseful prediction delays, and staggered CSS animations.
122
+ - **Zero Trust Security:** Strictly operates via environment variables. No secrets or `.env` files are tracked in the repository.
123
+ - **Seamless HF Spaces Deploy:** Fully configured with a multi-stage Dockerfile and Git LFS for automated Hugging Face Spaces deployment.
124
+
125
+ ## 🛠️ Technical Stack
126
+
127
+ - **Backend:** Python (`FastAPI`, `uvicorn`)
128
+ - **Machine Learning:** `PyTorch`, `torchvision`, `OpenCV` (Headless)
129
+ - **Frontend:** JavaScript (`Next.js`, React, CSS Modules)
130
+ - **Tooling:** `uv` (dependency management), `Ruff` (linting/formatting), `mypy` (static typing)
131
+ - **Infrastructure:** Docker (Multi-stage build), Hugging Face Spaces
132
+
133
+ ## 🏗️ Architecture & SDD
134
+
135
+ The project strictly adheres to **Spec-Driven Development (SDD)**. All architectural decisions, API contracts, frontend/backend specifications, and project constitutions are heavily documented.
136
+
137
+ To maintain a clean public repository, these internal specifications are isolated within the `docs/` folder, which is excluded via `.gitignore` and `.dockerignore`.
138
+
139
+ Key principles include:
140
+ 1. **Monolithic Containerized:** Single Docker container serving both REST API and static frontend.
141
+ 2. **FastAPI Serve All:** Uvicorn handles both API endpoints and SPA static file fallback (`html=True`).
142
+ 3. **In-Memory Model:** PyTorch weights (`*.pth` tracked via Git LFS) are loaded once at startup. **Note**: The production environment deploys the model trained on **Seed 25** because it achieved the lowest validation loss (Val Loss: 0.9864) among all experimental runs, ensuring superior generalization.
143
+
144
+ ## 🚀 Deployment and Production Architecture
145
+
146
+ To transition from the experimental environment to production deployment, the trained artifact is served natively through a high-performance backend engineered to optimize inference workflows. The deployment architecture leverages zero-copy memory structured inference, a technique that minimizes tensor serialization and deserialization overhead, ensuring ultra-low latencies in the millisecond range.
147
+
148
+ This infrastructure enables the model to process requests with stability and near-instantaneous response times, ensuring that the methodological rigor maintained during training translates into a final user experience defined by high availability and real-time predictive precision. Consequently, the system not only meets scientific validation requirements but also establishes itself as a technically viable solution for visual recognition tasks involving high morphological complexity.
149
+
150
+ ## 📄 License
151
+
152
+ This project is licensed under the MIT License.
backend/app.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from contextlib import asynccontextmanager
3
+ from typing import AsyncGenerator
4
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Depends, Request
5
+ from fastapi.responses import JSONResponse, HTMLResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ import torch
9
+
10
+ from config import settings
11
+ 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
18
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
19
+ """Lifecycle manager to load models and data into memory at startup."""
20
+ print("Initializing Pokédex Web Backend...")
21
+
22
+ # Instantiate services and hold them inside app.state container (prevents global mutability)
23
+ app.state.pokemon_service = PokemonService(settings.DATABASE_PATH)
24
+
25
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
+ app.state.device = device
27
+ print(f"Using device: {device}")
28
+
29
+ print(f"Loading model from {settings.MODEL_PATH}...")
30
+ model = PokedexNet(num_classes=settings.NUM_CLASSES)
31
+
32
+ if os.path.exists(settings.MODEL_PATH):
33
+ state_dict = torch.load(settings.MODEL_PATH, map_location=device, weights_only=True)
34
+ model.load_state_dict(state_dict)
35
+ model.to(device)
36
+ model.eval()
37
+ app.state.model = model
38
+ print("Model loaded successfully.")
39
+ else:
40
+ app.state.model = None
41
+ print(f"WARNING: Model file not found at {settings.MODEL_PATH}. Inference will fail.")
42
+
43
+ yield
44
+ print("Shutting down Pokédex Web Backend...")
45
+
46
+
47
+ app = FastAPI(
48
+ title="Pokédex Web API",
49
+ version=settings.VERSION,
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,
57
+ allow_credentials=True,
58
+ allow_methods=["*"],
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:
67
+ raise HTTPException(status_code=503, detail="Service not initialized")
68
+ return service
69
+
70
+
71
+ def get_model(request: Request) -> PokedexNet:
72
+ model = getattr(request.app.state, "model", None)
73
+ if not model:
74
+ raise HTTPException(status_code=503, detail="Model is not loaded.")
75
+ return model
76
+
77
+
78
+ def get_device(request: Request) -> torch.device:
79
+ device = getattr(request.app.state, "device", None)
80
+ if not device:
81
+ raise HTTPException(status_code=503, detail="Device is not set.")
82
+ return device
83
+
84
+
85
+ @app.get("/api/health")
86
+ async def health_check(request: Request):
87
+ """Health check endpoint for monitoring."""
88
+ service = get_pokemon_service(request)
89
+ model_obj = getattr(request.app.state, "model", None)
90
+ device_obj = getattr(request.app.state, "device", None)
91
+
92
+ return {
93
+ "status": "healthy",
94
+ "model_loaded": model_obj is not None,
95
+ "device": str(device_obj) if device_obj else "Not set",
96
+ "version": settings.VERSION,
97
+ "pokemon_count": len(service.pokemon_data)
98
+ }
99
+
100
+
101
+ @app.get("/api/pokemon")
102
+ async def list_pokemon(service: PokemonService = Depends(get_pokemon_service)):
103
+ """Returns basic information for all available Pokémon."""
104
+ return {
105
+ "total": len(service.pokemon_data),
106
+ "pokemon": service.get_all()
107
+ }
108
+
109
+
110
+ @app.get("/api/pokemon/{pokemon_id}")
111
+ async def get_pokemon(pokemon_id: int, service: PokemonService = Depends(get_pokemon_service)):
112
+ """Returns detailed information for a specific Pokémon."""
113
+ data = await service.get_by_id(pokemon_id)
114
+ if not data:
115
+ raise HTTPException(status_code=404, detail=f"Pokémon #{pokemon_id} not found.")
116
+ return data
117
+
118
+
119
+ @app.post(
120
+ "/api/predict",
121
+ dependencies=[Depends(get_rate_limiter(settings.RATE_LIMIT_REQUESTS, settings.RATE_LIMIT_WINDOW))]
122
+ )
123
+ async def predict(
124
+ file: UploadFile = File(...),
125
+ debug: bool = Query(False, description="Include processed silhouette in base64"),
126
+ service: PokemonService = Depends(get_pokemon_service),
127
+ model: PokedexNet = Depends(get_model),
128
+ device: torch.device = Depends(get_device)
129
+ ):
130
+ """
131
+ Receives an image, extracts its silhouette, and identifies the Pokémon.
132
+ Validates file size and type. Supports sliding-window rate limiting.
133
+ """
134
+ if file.content_type not in settings.ALLOWED_TYPES:
135
+ raise HTTPException(
136
+ status_code=422,
137
+ detail="Invalid file type. Accepted formats: PNG, JPEG, WebP, GIF."
138
+ )
139
+
140
+ file_bytes = await file.read()
141
+ if len(file_bytes) > settings.MAX_UPLOAD_SIZE:
142
+ raise HTTPException(
143
+ status_code=422,
144
+ detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB."
145
+ )
146
+
147
+ try:
148
+ id_to_name_map = service.get_id_to_name_map()
149
+
150
+ result = predict_from_bytes(
151
+ file_bytes=file_bytes,
152
+ model=model,
153
+ device=device,
154
+ id_to_name=id_to_name_map,
155
+ include_debug=debug
156
+ )
157
+
158
+ return JSONResponse(content={
159
+ "pokemon_id": result.pokemon_id,
160
+ "name": result.name,
161
+ "confidence": result.confidence,
162
+ "detected_source": result.detected_source,
163
+ "top_5": result.top_5,
164
+ "debug_silhouette": result.debug_silhouette_b64
165
+ })
166
+
167
+ except ValueError as ve:
168
+ raise HTTPException(status_code=422, detail=str(ve))
169
+ except Exception as e:
170
+ print(f"Inference error: {e}")
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):
178
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
179
+ else:
180
+ @app.get("/")
181
+ async def dev_mode_fallback():
182
+ html_content = """
183
+ <!DOCTYPE html>
184
+ <html lang="en">
185
+ <head>
186
+ <meta charset="UTF-8">
187
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
188
+ <title>Pokédex API - Dev Mode</title>
189
+ <style>
190
+ body { background-color: #0a0a0f; color: #f0f0f5; font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
191
+ .container { background: rgba(255,255,255,0.05); padding: 3rem; border-radius: 16px; border: 1px solid rgba(255,255,255,0.1); text-align: center; max-width: 500px; }
192
+ h1 { color: #ff3d3d; margin-top: 0; font-size: 2rem; }
193
+ p { color: #8888a0; line-height: 1.5; margin-bottom: 2rem; }
194
+ a { display: inline-block; padding: 12px 24px; background: linear-gradient(135deg, #ff3d3d, #ff6b35); color: white; text-decoration: none; border-radius: 24px; font-weight: bold; transition: opacity 0.2s; }
195
+ a:hover { opacity: 0.9; }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <div class="container">
200
+ <h1>Pokédex API Active</h1>
201
+ <p>The FastAPI backend is running perfectly.</p>
202
+ <p>If you are developing locally, please access the UI through the Next.js development server on port 3000.</p>
203
+ <a href="http://localhost:3000">Go to Frontend (Port 3000)</a>
204
+ </div>
205
+ </body>
206
+ </html>
207
+ """
208
+ return HTMLResponse(content=html_content)
backend/config.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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():
8
+ try:
9
+ with open(env_file, "r", encoding="utf-8") as f:
10
+ for line in f:
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:
18
+ print(f"Warning: Failed to load .env file: {e}")
19
+
20
+
21
+ class Settings:
22
+ """Application settings using environment variables with sensible defaults."""
23
+
24
+ BASE_DIR: Path = Path(__file__).parent
25
+
26
+ MODEL_PATH: str = os.getenv("POKEDEX_MODEL_PATH", str(BASE_DIR / "pokedex_model.pth"))
27
+ DATABASE_PATH: str = os.getenv("POKEDEX_DB_PATH", str(BASE_DIR / "poke_database.json"))
28
+
29
+ NUM_CLASSES: int = int(os.getenv("POKEDEX_NUM_CLASSES", "1025"))
30
+ MAX_UPLOAD_SIZE: int = int(os.getenv("POKEDEX_MAX_UPLOAD_SIZE", str(10 * 1024 * 1024))) # 10MB
31
+
32
+ ALLOWED_TYPES: set[str] = {"image/png", "image/jpeg", "image/webp", "image/gif"}
33
+
34
+ POKEAPI_BASE: str = os.getenv("POKEAPI_BASE", "https://pokeapi.co/api/v2")
35
+ POKEAPI_CACHE: str = os.getenv("POKEAPI_CACHE", "/tmp/pokeapi_cache.json")
36
+
37
+ # CORS Configurations from .env
38
+ CORS_ORIGINS: list[str] = [
39
+ origin.strip()
40
+ for origin in os.getenv("POKEDEX_CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:7860").split(",")
41
+ if origin.strip()
42
+ ]
43
+
44
+ # Rate Limiting Configurations from .env
45
+ RATE_LIMIT_REQUESTS: int = int(os.getenv("POKEDEX_RATE_LIMIT_REQUESTS", "10"))
46
+ RATE_LIMIT_WINDOW: int = int(os.getenv("POKEDEX_RATE_LIMIT_WINDOW", "60"))
47
+
48
+ # PokéAPI Cache Configurations from .env (default: 30 days)
49
+ POKEAPI_CACHE_TTL: int = int(os.getenv("POKEDEX_POKEAPI_CACHE_TTL", "2592000"))
50
+
51
+ VERSION: str = "1.0.0"
52
+ STATIC_DIR: str = "static"
53
+
54
+
55
+ settings = Settings()
backend/model.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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)
8
+ self.backbone.conv1 = nn.Conv2d(1, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
9
+ num_ftrs = self.backbone.fc.in_features
10
+ self.backbone.fc = nn.Linear(num_ftrs, num_classes)
11
+
12
+ def forward(self, x):
13
+ return self.backbone(x)
backend/poke_database.json ADDED
The diff for this file is too large to render. See raw diff
 
backend/pokedex_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0ed0d8fb247d7f40bb1162ebed68da3fbbdb147d0e435f02f216500bbae99925
3
+ size 46867851
backend/pokemon_service.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import time
4
+ import asyncio
5
+ from typing import Dict, List, Any, Optional
6
+ import httpx
7
+
8
+ from config import settings
9
+
10
+
11
+ class PokemonService:
12
+ """Service to handle Pokémon data from local database and PokeAPI enrichment."""
13
+
14
+ def __init__(self, database_path: str) -> None:
15
+ self.database_path = database_path
16
+ self.pokemon_data: List[Dict[str, Any]] = []
17
+ self.id_to_name: Dict[int, str] = {}
18
+ self.cache_lock = asyncio.Lock() # Asymmetric async lock to prevent race conditions on writes
19
+ self._load_database()
20
+
21
+ def _load_database(self) -> None:
22
+ """Loads the local JSON database into memory."""
23
+ try:
24
+ with open(self.database_path, "r", encoding="utf-8") as f:
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}")
33
+
34
+ def get_id_to_name_map(self) -> Dict[int, str]:
35
+ """Returns a zero-indexed mapping of model output to Pokémon names."""
36
+ return self.id_to_name
37
+
38
+ def get_all(self) -> List[Dict[str, Any]]:
39
+ """Returns all Pokémon basic info."""
40
+ return [{"id": p["id"], "name": p["name"]} for p in self.pokemon_data]
41
+
42
+ async def get_by_id(self, pokemon_id: int) -> Optional[Dict[str, Any]]:
43
+ """
44
+ Returns enriched Pokémon data.
45
+ Fetches types and stats from PokeAPI and caches them in /tmp.
46
+ """
47
+ basic_info = next((p for p in self.pokemon_data if p["id"] == pokemon_id), None)
48
+ if not basic_info:
49
+ return None
50
+
51
+ name = basic_info["name"]
52
+ sprite_url = f"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{pokemon_id}.png"
53
+
54
+ response_data = {
55
+ "id": pokemon_id,
56
+ "name": name,
57
+ "sprite_url": sprite_url,
58
+ "types": [],
59
+ "stats": {}
60
+ }
61
+
62
+ enriched = await self._enrich_from_pokeapi(pokemon_id)
63
+ if enriched:
64
+ response_data["types"] = enriched.get("types", [])
65
+ response_data["stats"] = enriched.get("stats", {})
66
+
67
+ return response_data
68
+
69
+ async def _enrich_from_pokeapi(self, pokemon_id: int) -> Optional[Dict[str, Any]]:
70
+ """Fetches and caches types and stats from PokeAPI with thread-safe lock and TTL validation."""
71
+ str_id = str(pokemon_id)
72
+
73
+ # Acquire asyncio Lock to prevent multiple concurrent requests from triggering race conditions
74
+ async with self.cache_lock:
75
+ cache = self._load_cache()
76
+
77
+ # Check cache with TTL and handle migration/backward compatibility
78
+ if str_id in cache:
79
+ entry = cache[str_id]
80
+ if isinstance(entry, dict) and "cached_at" in entry and "data" in entry:
81
+ # Valid schema: check TTL (default 30 days)
82
+ if time.time() - entry["cached_at"] < settings.POKEAPI_CACHE_TTL:
83
+ return entry["data"]
84
+ elif not isinstance(entry, dict) or "cached_at" not in entry:
85
+ # Backward compatibility / migration: old cache without timestamp.
86
+ # Automatically migrate it to the new schema for future correctness
87
+ migrated_entry = {
88
+ "data": entry,
89
+ "cached_at": time.time()
90
+ }
91
+ cache[str_id] = migrated_entry
92
+ self._save_cache(cache)
93
+ return entry # Return the data as is
94
+
95
+ # Cache miss or expired: fetch fresh data from PokeAPI
96
+ url = f"{settings.POKEAPI_BASE}/pokemon/{pokemon_id}/"
97
+ try:
98
+ async with httpx.AsyncClient(timeout=5.0) as client:
99
+ response = await client.get(url)
100
+ if response.status_code == 200:
101
+ data = response.json()
102
+ types = [t["type"]["name"] for t in data.get("types", [])]
103
+ stats = {s["stat"]["name"]: s["base_stat"] for s in data.get("stats", [])}
104
+
105
+ mapped_stats = {
106
+ "hp": stats.get("hp", 0),
107
+ "attack": stats.get("attack", 0),
108
+ "defense": stats.get("defense", 0),
109
+ "sp_attack": stats.get("special-attack", 0),
110
+ "sp_defense": stats.get("special-defense", 0),
111
+ "speed": stats.get("speed", 0)
112
+ }
113
+
114
+ enriched_data = {"types": types, "stats": mapped_stats}
115
+
116
+ # Store in cache with the new timestamp schema
117
+ cache[str_id] = {
118
+ "data": enriched_data,
119
+ "cached_at": time.time()
120
+ }
121
+ self._save_cache(cache)
122
+ return enriched_data
123
+ except Exception as e:
124
+ print(f"PokeAPI enrichment failed for ID {pokemon_id}: {e}")
125
+
126
+ return None
127
+
128
+ def _load_cache(self) -> Dict[str, Any]:
129
+ """Loads PokeAPI cache from the temporary writable directory."""
130
+ if os.path.exists(settings.POKEAPI_CACHE):
131
+ try:
132
+ with open(settings.POKEAPI_CACHE, "r", encoding="utf-8") as f:
133
+ return json.load(f)
134
+ except Exception:
135
+ return {}
136
+ return {}
137
+
138
+ def _save_cache(self, cache: Dict[str, Any]) -> None:
139
+ """Saves PokeAPI cache to the temporary writable directory."""
140
+ try:
141
+ with open(settings.POKEAPI_CACHE, "w", encoding="utf-8") as f:
142
+ json.dump(cache, f)
143
+ except Exception as e:
144
+ print(f"Failed to save cache: {e}")
backend/predict.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import cv2
3
+ import numpy as np
4
+ import torch
5
+ import torch.nn.functional as F
6
+ from torchvision import transforms # type: ignore
7
+ from PIL import Image
8
+ from dataclasses import dataclass
9
+ from typing import Tuple, List, Dict, Optional, Any
10
+
11
+ from model import PokedexNet
12
+
13
+
14
+ @dataclass
15
+ class PredictionResult:
16
+ """Data contract for the prediction response."""
17
+ pokemon_id: int
18
+ name: str
19
+ confidence: float
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:
26
+ """Centers the Pokémon silhouette and adds a 20% margin."""
27
+ coords = cv2.findNonZero(255 - mask)
28
+ if coords is None:
29
+ return mask
30
+
31
+ x, y, w, h = cv2.boundingRect(coords)
32
+ pokemon_crop = mask[y:y + h, x:x + w]
33
+
34
+ longest_side = max(w, h)
35
+ margin = int(longest_side * 0.20)
36
+ final_size = longest_side + margin * 2
37
+
38
+ canvas = np.full((final_size, final_size), 255, dtype=np.uint8)
39
+ y_off = (final_size - h) // 2
40
+ x_off = (final_size - w) // 2
41
+ canvas[y_off:y_off + h, x_off:x_off + w] = pokemon_crop
42
+
43
+ return canvas
44
+
45
+
46
+ def _mask_to_tensor(mask: np.ndarray) -> torch.Tensor:
47
+ """Converts a numpy mask (uint8 grayscale) to a 1x1x128x128 tensor."""
48
+ silhouette = Image.fromarray(mask).convert("L")
49
+ transform = transforms.Compose([
50
+ transforms.Resize((128, 128)),
51
+ transforms.ToTensor(),
52
+ ])
53
+ tensor = transform(silhouette).unsqueeze(0)
54
+ return tensor
55
+
56
+
57
+ def _largest_contour_mask(binary_mask: np.ndarray, full_shape: Tuple[int, ...]) -> np.ndarray:
58
+ """Returns a mask containing only the largest contour by area."""
59
+ contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
60
+ result = np.full(full_shape[:2], 255, dtype=np.uint8)
61
+ if contours:
62
+ largest = max(contours, key=cv2.contourArea)
63
+ cv2.drawContours(result, [largest], -1, 0, thickness=cv2.FILLED)
64
+ return result
65
+
66
+
67
+ def _detect_anime_type(img_bgr: np.ndarray) -> str:
68
+ """Detects the origin of the image to apply the correct extraction strategy."""
69
+ h, w = img_bgr.shape[:2]
70
+ work_w = int(w * 0.65)
71
+
72
+ hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
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
+
80
+ blue_bg_ratio = cv2.countNonZero(geroid_blue) / (h * w)
81
+ dark_ratio = cv2.countNonZero(dark_pixels) / (h * w)
82
+
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)
102
+ if red_ratio > 0.05:
103
+ return "old"
104
+
105
+ return "plain"
106
+
107
+
108
+ def _extract_alpha(img_bgra: np.ndarray) -> Optional[np.ndarray]:
109
+ """Uses the alpha channel to generate the mask if applicable."""
110
+ alpha = img_bgra[:, :, 3]
111
+ total_pixels = alpha.shape[0] * alpha.shape[1]
112
+
113
+ if cv2.countNonZero(alpha) < total_pixels:
114
+ _, mask = cv2.threshold(alpha, 0, 255, cv2.THRESH_BINARY_INV)
115
+ return mask
116
+ return None
117
+
118
+
119
+ def _extract_new_anime(img_bgr: np.ndarray) -> np.ndarray:
120
+ """Extracts solid blue silhouette from the modern anime version."""
121
+ h, w = img_bgr.shape[:2]
122
+ work_w = int(w * 0.65)
123
+ hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
124
+ hsv_crop = hsv[:, :work_w, :]
125
+
126
+ blue_mask = cv2.inRange(hsv_crop, np.array([88, 120, 60]), np.array([135, 255, 220]))
127
+
128
+ k3 = np.ones((3, 3), np.uint8)
129
+ k5 = np.ones((5, 5), np.uint8)
130
+ blue_mask = cv2.morphologyEx(blue_mask, cv2.MORPH_OPEN, k3, iterations=2)
131
+ blue_mask = cv2.morphologyEx(blue_mask, cv2.MORPH_CLOSE, k5, iterations=4)
132
+
133
+ return _largest_contour_mask(blue_mask, (h, w))
134
+
135
+
136
+ def _extract_old_anime(img_bgr: np.ndarray) -> np.ndarray:
137
+ """Extracts black silhouette from the classic anime version."""
138
+ h, w = img_bgr.shape[:2]
139
+ work_w = int(w * 0.65)
140
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
141
+ gray_crop = gray[:, :work_w]
142
+
143
+ _, dark = cv2.threshold(gray_crop, 50, 255, cv2.THRESH_BINARY_INV)
144
+
145
+ k3 = np.ones((3, 3), np.uint8)
146
+ k5 = np.ones((5, 5), np.uint8)
147
+ dark = cv2.morphologyEx(dark, cv2.MORPH_OPEN, k3, iterations=2)
148
+ dark = cv2.morphologyEx(dark, cv2.MORPH_CLOSE, k5, iterations=3)
149
+
150
+ return _largest_contour_mask(dark, (h, w))
151
+
152
+
153
+ def _extract_plain(img_bgr: np.ndarray) -> np.ndarray:
154
+ """Extracts dark silhouette from a light/white background."""
155
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
156
+ h_img, w_img = gray.shape
157
+
158
+ _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
159
+ k3 = np.ones((3, 3), np.uint8)
160
+ thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, k3, iterations=2)
161
+
162
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
163
+ mask = np.full(gray.shape, 255, dtype=np.uint8)
164
+
165
+ if contours:
166
+ valid = []
167
+ for cnt in contours:
168
+ bx, by, bw, bh = cv2.boundingRect(cnt)
169
+ if bx > 5 and by > 5 and (bx + bw) < (w_img - 5) and (by + bh) < (h_img - 5):
170
+ valid.append(cnt)
171
+
172
+ target = max(valid, key=cv2.contourArea) if valid else max(contours, key=cv2.contourArea)
173
+ cv2.drawContours(mask, [target], -1, 0, thickness=cv2.FILLED)
174
+
175
+ return mask
176
+
177
+
178
+ def _extract_monkepo(img_bgr: np.ndarray) -> np.ndarray:
179
+ """Extracts grey silhouette without cutting wider Pokémon."""
180
+ h, w = img_bgr.shape[:2]
181
+ hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
182
+
183
+ lower_grey = np.array([0, 0, 50])
184
+ upper_grey = np.array([180, 50, 180])
185
+
186
+ mask_grey = cv2.inRange(hsv, lower_grey, upper_grey)
187
+
188
+ logo_y = int(h * 0.82)
189
+ logo_x = int(w * 0.75)
190
+ mask_grey[logo_y:, logo_x:] = 0
191
+
192
+ k3 = np.ones((3, 3), np.uint8)
193
+ mask_grey = cv2.morphologyEx(mask_grey, cv2.MORPH_CLOSE, k3, iterations=1)
194
+
195
+ return _largest_contour_mask(mask_grey, (h, w))
196
+
197
+
198
+ def _extract_geroid(img_bgr: np.ndarray) -> np.ndarray:
199
+ """Extracts silhouette from Geroid website."""
200
+ h, w = img_bgr.shape[:2]
201
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
202
+
203
+ _, mask = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV)
204
+
205
+ k3 = np.ones((3, 3), np.uint8)
206
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k3, iterations=1)
207
+
208
+ return _largest_contour_mask(mask, (h, w))
209
+
210
+
211
+ def predict_from_bytes(
212
+ file_bytes: bytes,
213
+ model: PokedexNet,
214
+ device: torch.device,
215
+ id_to_name: Dict[int, str],
216
+ include_debug: bool = False
217
+ ) -> PredictionResult:
218
+ """
219
+ Main pipeline to process an image from bytes in memory and infer the Pokémon.
220
+ Executes purely in-memory (Zero Disk Writes).
221
+ """
222
+ np_arr = np.frombuffer(file_bytes, np.uint8)
223
+ img = cv2.imdecode(np_arr, cv2.IMREAD_UNCHANGED)
224
+
225
+ if img is None:
226
+ raise ValueError("Could not decode image bytes. Unsupported or corrupted format.")
227
+
228
+ mask: Optional[np.ndarray] = None
229
+ detected_source = "plain"
230
+
231
+ has_alpha = len(img.shape) == 3 and img.shape[2] == 4
232
+ if has_alpha:
233
+ mask = _extract_alpha(img)
234
+ if mask is not None:
235
+ detected_source = "alpha"
236
+
237
+ if mask is None:
238
+ img_bgr = img[:, :, :3] if has_alpha else img
239
+ detected_source = _detect_anime_type(img_bgr)
240
+
241
+ if detected_source == "geroid":
242
+ mask = _extract_geroid(img_bgr)
243
+ elif detected_source == "monkepo":
244
+ mask = _extract_monkepo(img_bgr)
245
+ elif detected_source == "new":
246
+ mask = _extract_new_anime(img_bgr)
247
+ elif detected_source == "old":
248
+ mask = _extract_old_anime(img_bgr)
249
+ else:
250
+ mask = _extract_plain(img_bgr)
251
+
252
+ final_mask = _center_and_pad(mask)
253
+
254
+ debug_b64 = None
255
+ if include_debug:
256
+ _, buffer = cv2.imencode(".png", final_mask)
257
+ debug_b64 = base64.b64encode(buffer).decode("utf-8")
258
+
259
+ input_tensor = _mask_to_tensor(final_mask).to(device)
260
+
261
+ with torch.no_grad():
262
+ output = model(input_tensor)
263
+ probabilities = F.softmax(output, dim=1)
264
+
265
+ top_probs, top_indices = torch.topk(probabilities, 5, dim=1)
266
+
267
+ top_5_list = []
268
+ for i in range(5):
269
+ idx = int(top_indices[0][i].item())
270
+ prob = float(top_probs[0][i].item())
271
+ pokemon_id = idx + 1
272
+ name = id_to_name.get(idx, "Unknown")
273
+
274
+ top_5_list.append({
275
+ "pokemon_id": pokemon_id,
276
+ "name": name,
277
+ "confidence": prob
278
+ })
279
+
280
+ best_pred = top_5_list[0]
281
+
282
+ return PredictionResult(
283
+ pokemon_id=int(best_pred["pokemon_id"]), # type: ignore
284
+ name=str(best_pred["name"]),
285
+ confidence=float(best_pred["confidence"]), # type: ignore
286
+ detected_source=detected_source,
287
+ top_5=top_5_list,
288
+ debug_silhouette_b64=debug_b64
289
+ )
backend/rate_limiter.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import threading
3
+ from collections import defaultdict
4
+ from fastapi import Request, HTTPException
5
+
6
+
7
+ class SimpleRateLimiter:
8
+ """A thread-safe, in-memory sliding-window rate limiter."""
9
+
10
+ def __init__(self, requests_limit: int, window_seconds: int) -> None:
11
+ self.requests_limit = requests_limit
12
+ self.window_seconds = window_seconds
13
+ self.client_records = defaultdict(list)
14
+ self.lock = threading.Lock()
15
+
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
+
38
+ async def dependency(request: Request) -> None:
39
+ client_ip = request.client.host if request.client else "127.0.0.1"
40
+ if not limiter.is_allowed(client_ip):
41
+ raise HTTPException(
42
+ status_code=429,
43
+ detail="Too many requests. Please try again later."
44
+ )
45
+
46
+ return dependency
backend/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ python-multipart>=0.0.9
4
+ torch>=2.0.0
5
+ torchvision>=0.15.0
6
+ opencv-python-headless>=4.8.0
7
+ numpy>=1.24.0
8
+ Pillow>=10.0.0
9
+ httpx>=0.27.0
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+
4
+ const eslintConfig = defineConfig([
5
+ ...nextVitals,
6
+ // Override default ignores of eslint-config-next.
7
+ globalIgnores([
8
+ // Default ignores of eslint-config-next:
9
+ ".next/**",
10
+ "out/**",
11
+ "build/**",
12
+ "next-env.d.ts",
13
+ ]),
14
+ ]);
15
+
16
+ export default eslintConfig;
frontend/jsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "@/*": ["./src/*"]
5
+ }
6
+ }
7
+ }
frontend/next.config.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ output: 'export',
4
+ trailingSlash: true,
5
+ images: { unoptimized: true },
6
+ allowedDevOrigins: ['192.168.0.31', 'localhost'],
7
+ };
8
+
9
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "canvas-confetti": "^1.9.4",
13
+ "next": "16.2.5",
14
+ "react": "19.2.4",
15
+ "react-dom": "19.2.4"
16
+ },
17
+ "devDependencies": {
18
+ "eslint": "^9",
19
+ "eslint-config-next": "16.2.5"
20
+ }
21
+ }
frontend/public/file.svg ADDED
frontend/public/globe.svg ADDED
frontend/public/next.svg ADDED
frontend/public/static/images/1.png ADDED

Git LFS Details

  • SHA256: 7233d8d8b03b5fa91d835a3069d25769155db93c9ef91b6066e215ed3895e9d8
  • Pointer size: 131 Bytes
  • Size of remote file: 203 kB
frontend/public/static/images/augmentation_visual.png ADDED

Git LFS Details

  • SHA256: cd8a0125dea0dc2b778c791850a96e9c55bb25e5e80b1e3e368221ac176deefb
  • Pointer size: 130 Bytes
  • Size of remote file: 93.9 kB
frontend/public/static/images/digimon.png ADDED

Git LFS Details

  • SHA256: 441adf62b530e2fec1be46b71202073162cb22a9c9efb8aa208f27ff04edbec8
  • Pointer size: 130 Bytes
  • Size of remote file: 61.9 kB
frontend/public/static/images/mosaic_confusion_extreme_cases.png ADDED

Git LFS Details

  • SHA256: f801f4f6046ce251ec1b0caedd40524a006be041c75886f5ef3ad6593cfd777a
  • Pointer size: 131 Bytes
  • Size of remote file: 376 kB
frontend/public/static/images/pipeline_visual.png ADDED

Git LFS Details

  • SHA256: 941f4dcf0e6d31a0cb5b4a3fa1811cace4378daf04f39af46b006146bbede9b4
  • Pointer size: 131 Bytes
  • Size of remote file: 451 kB
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_121.pth.png ADDED

Git LFS Details

  • SHA256: cebaf5713c3703039e5d321f8ea011607f7b9f1afbce6dc275117ccf1fdaac1d
  • Pointer size: 131 Bytes
  • Size of remote file: 133 kB
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_25.pth.png ADDED

Git LFS Details

  • SHA256: 98ed750aebf991517dd35fbf7622708c28372d7466b234609ebc441b044101e3
  • Pointer size: 131 Bytes
  • Size of remote file: 138 kB
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_255.pth.png ADDED

Git LFS Details

  • SHA256: 5bde39bb9170bd453706173f8f00693e32ba4ad66c64b1b551a3da595d9adc6c
  • Pointer size: 131 Bytes
  • Size of remote file: 131 kB
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_3.pth.png ADDED

Git LFS Details

  • SHA256: 7d5875eafc3efeec0191ac8ccdb21d295f01ee7b18befbefb5414d4fb6820e0f
  • Pointer size: 131 Bytes
  • Size of remote file: 135 kB
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_7.pth.png ADDED

Git LFS Details

  • SHA256: 6652210abc4f013f56902ee604df85521260c05c94d7ed7cfecd0025588f2814
  • Pointer size: 131 Bytes
  • Size of remote file: 135 kB
frontend/public/static/images/training_curves_multirun_seed_121.png ADDED

Git LFS Details

  • SHA256: bfc96eb90eecc3062b12537b3bbf775f9e68ec4c94aab68554be1db216354837
  • Pointer size: 131 Bytes
  • Size of remote file: 279 kB
frontend/public/static/images/training_curves_multirun_seed_25.png ADDED

Git LFS Details

  • SHA256: eb73f440b327eb135db46dd7e90bb3725f1a61dc9fb0dfb8b32e08c577fb9ff7
  • Pointer size: 131 Bytes
  • Size of remote file: 274 kB
frontend/public/static/images/training_curves_multirun_seed_255.png ADDED

Git LFS Details

  • SHA256: bca6a1df757b270b7c9b18e5ae4da32fd01f0e5b09d449c2a21b8d4861b202d7
  • Pointer size: 131 Bytes
  • Size of remote file: 263 kB
frontend/public/static/images/training_curves_multirun_seed_3.png ADDED

Git LFS Details

  • SHA256: b60bff1d01322d62a067cc59d578e624a24589b56b1c381c845b4077372ae81f
  • Pointer size: 131 Bytes
  • Size of remote file: 263 kB
frontend/public/static/images/training_curves_multirun_seed_7.png ADDED

Git LFS Details

  • SHA256: 233683a797a08c1449e67d97ce853923ddbb815faa3ead7bb0b9219b4c74dbe8
  • Pointer size: 131 Bytes
  • Size of remote file: 263 kB
frontend/public/vercel.svg ADDED
frontend/public/window.svg ADDED
frontend/src/app/favicon.ico ADDED
frontend/src/app/globals.css ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap');
2
+
3
+ :root {
4
+ --bg-primary: #0a0514;
5
+ --bg-secondary: #170b2b;
6
+ --bg-glass: rgba(255, 255, 255, 0.08);
7
+ --border-glass: rgba(255, 255, 255, 0.25);
8
+ --accent-primary: #38bdf8;
9
+ --accent-secondary: #c084fc;
10
+ --text-primary: #ffffff;
11
+ --text-secondary: #cbd5e1;
12
+
13
+ --font-display: 'Unbounded', sans-serif;
14
+ --font-body: 'Plus Jakarta Sans', sans-serif;
15
+
16
+ /* Type Colors */
17
+ --type-fire: #FF5A00;
18
+ --type-water: #0084FF;
19
+ --type-grass: #52E028;
20
+ --type-electric: #FFD600;
21
+ --type-ice: #00F0FF;
22
+ --type-fighting: #FF003C;
23
+ --type-poison: #A200FF;
24
+ --type-ground: #E5A93D;
25
+ --type-flying: #7D92FF;
26
+ --type-psychic: #FF008A;
27
+ --type-bug: #94D800;
28
+ --type-rock: #C1A354;
29
+ --type-ghost: #7B42FF;
30
+ --type-dragon: #4200FF;
31
+ --type-dark: #352B3E;
32
+ --type-steel: #A1A1BA;
33
+ --type-fairy: #FF7BD1;
34
+ --type-normal: #A8A89A;
35
+ }
36
+
37
+ * {
38
+ box-sizing: border-box;
39
+ padding: 0;
40
+ margin: 0;
41
+ }
42
+
43
+ body {
44
+ background-color: var(--bg-primary);
45
+ color: var(--text-primary);
46
+ font-family: var(--font-body);
47
+ min-height: 100vh;
48
+ overflow-x: hidden;
49
+ position: relative;
50
+ }
51
+
52
+ /* Dynamic Gradient Mesh & Noise Texture */
53
+ body::before, body::after {
54
+ content: "";
55
+ position: fixed;
56
+ top: 0;
57
+ left: 0;
58
+ width: 100vw;
59
+ height: 100vh;
60
+ z-index: -2;
61
+ pointer-events: none;
62
+ }
63
+
64
+ body::before {
65
+ /* Noise Texture SVG Data URI */
66
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
67
+ opacity: 0.03;
68
+ mix-blend-mode: overlay;
69
+ z-index: -1;
70
+ }
71
+
72
+ body::after {
73
+ /* Vibrant liquid Aurora Mesh */
74
+ background:
75
+ radial-gradient(ellipse at 20% 40%, rgba(56, 189, 248, 0.4) 0%, transparent 60%),
76
+ radial-gradient(ellipse at 80% 30%, rgba(192, 132, 252, 0.4) 0%, transparent 60%),
77
+ radial-gradient(ellipse at 50% 80%, rgba(59, 130, 246, 0.3) 0%, transparent 60%),
78
+ radial-gradient(ellipse at 80% 90%, rgba(232, 121, 249, 0.2) 0%, transparent 60%);
79
+ animation: meshFloat 15s ease-in-out infinite alternate;
80
+ filter: blur(40px); /* Blurs the shapes completely to make it liquid */
81
+ }
82
+
83
+ @keyframes meshFloat {
84
+ 0% { transform: scale(1) translate(0, 0); }
85
+ 50% { transform: scale(1.1) translate(2%, -2%); }
86
+ 100% { transform: scale(1) translate(-2%, 2%); }
87
+ }
88
+
89
+ .mono {
90
+ font-family: 'JetBrains Mono', monospace;
91
+ }
92
+
93
+ /* Deep Glassmorphism Utilities */
94
+ .glass-panel {
95
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02));
96
+ backdrop-filter: blur(24px) saturate(160%);
97
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
98
+ border: 1px solid var(--border-glass);
99
+ border-radius: 24px;
100
+ box-shadow:
101
+ inset 0 1px 1px rgba(255, 255, 255, 0.2),
102
+ 0 20px 40px rgba(0, 0, 0, 0.4);
103
+ }
104
+
105
+ .glow-primary {
106
+ box-shadow: 0 0 40px rgba(255, 42, 42, 0.3);
107
+ }
108
+
109
+ /* Typography Helpers */
110
+ h1, h2, h3, h4, h5, h6 {
111
+ font-family: var(--font-display);
112
+ }
113
+
114
+ .btn {
115
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
116
+ color: #fff;
117
+ border: none;
118
+ border-radius: 30px;
119
+ padding: 16px 32px;
120
+ font-family: var(--font-body);
121
+ font-weight: 700;
122
+ font-size: 1.1rem;
123
+ letter-spacing: 1px;
124
+ cursor: pointer;
125
+ transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
126
+ box-shadow: 0 10px 30px rgba(255, 42, 42, 0.3);
127
+ text-transform: uppercase;
128
+ }
129
+
130
+ .btn:hover {
131
+ transform: translateY(-4px) scale(1.02);
132
+ box-shadow: 0 15px 40px rgba(255, 42, 42, 0.5);
133
+ }
134
+
135
+ .btn:disabled {
136
+ opacity: 0.5;
137
+ cursor: not-allowed;
138
+ transform: none;
139
+ box-shadow: none;
140
+ }
141
+
142
+ /* Asymmetric Grid Layout */
143
+ .main-layout {
144
+ padding: 4rem 2rem;
145
+ max-width: 1400px;
146
+ margin: 0 auto;
147
+ position: relative;
148
+ z-index: 1;
149
+ min-height: 100vh;
150
+ display: grid;
151
+ grid-template-columns: 1fr 1fr;
152
+ align-items: center;
153
+ gap: 4rem;
154
+ }
155
+
156
+ /* Tablets & medium screens */
157
+ @media (max-width: 968px) {
158
+ .main-layout {
159
+ grid-template-columns: 1fr;
160
+ padding: 6rem 2rem 5rem;
161
+ align-items: center;
162
+ justify-items: center;
163
+ gap: 4rem;
164
+ min-height: auto;
165
+ }
166
+ }
167
+
168
+ /* Small tablets */
169
+ @media (max-width: 768px) {
170
+ .main-layout {
171
+ padding: 5rem 1.5rem 4rem;
172
+ gap: 3.5rem;
173
+ }
174
+ }
175
+
176
+ /* Phones */
177
+ @media (max-width: 480px) {
178
+ .main-layout {
179
+ padding: 4.5rem 1rem 3rem;
180
+ gap: 3rem;
181
+ }
182
+ }
frontend/src/app/layout.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./globals.css";
2
+
3
+ export const metadata = {
4
+ title: "Pokédex AI — Who's That Pokémon?",
5
+ description: "A modern web application that uses computer vision (ResNet-18) to identify 1025 different Pokémon silhouettes.",
6
+ };
7
+
8
+ export default function RootLayout({ children }) {
9
+ return (
10
+ <html lang="en">
11
+ <body>{children}</body>
12
+ </html>
13
+ );
14
+ }
frontend/src/app/page.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useState, useRef } from 'react';
4
+ import ParticlesBg from '../components/ParticlesBg';
5
+ import Hero from '../components/Hero';
6
+ import UploadZone from '../components/UploadZone';
7
+ import LoadingOverlay from '../components/LoadingOverlay';
8
+ import ResultCard from '../components/ResultCard';
9
+
10
+ export default function Home() {
11
+ const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'success' | 'error'
12
+ const [predictionData, setPredictionData] = useState(null);
13
+ const [pokemonDetails, setPokemonDetails] = useState(null);
14
+ const [selectedImage, setSelectedImage] = useState(null);
15
+ const [errorMessage, setErrorMessage] = useState('');
16
+
17
+ const particlesRef = useRef(null);
18
+
19
+ const handleImageSelect = (blob, error, preview) => {
20
+ setErrorMessage(error || '');
21
+ if (blob) {
22
+ setSelectedImage({ blob, preview });
23
+ } else {
24
+ setSelectedImage(null);
25
+ }
26
+ };
27
+
28
+ const handlePredict = async () => {
29
+ if (!selectedImage || !selectedImage.blob) return;
30
+
31
+ setStatus('loading');
32
+ setErrorMessage('');
33
+
34
+ try {
35
+ const formData = new FormData();
36
+ formData.append('file', selectedImage.blob, 'upload.png');
37
+
38
+ // Artificial suspense delay requested by the user
39
+ await new Promise((resolve) => setTimeout(resolve, 500));
40
+
41
+ // Automatically route to 7860 in local dev, or use relative paths in production
42
+ const API_BASE = process.env.NODE_ENV === 'development' ? 'http://localhost:7860' : '';
43
+
44
+ const predictRes = await fetch(`${API_BASE}/api/predict`, {
45
+ method: 'POST',
46
+ body: formData,
47
+ });
48
+
49
+ if (!predictRes.ok) {
50
+ const errorData = await predictRes.json();
51
+ throw new Error(errorData.detail || 'Failed to identify Pokémon');
52
+ }
53
+
54
+ const predictData = await predictRes.json();
55
+ setPredictionData(predictData);
56
+
57
+ // Fetch details from API
58
+ const detailsRes = await fetch(`${API_BASE}/api/pokemon/${predictData.pokemon_id}`);
59
+ let detailsData = null;
60
+ if (detailsRes.ok) {
61
+ detailsData = await detailsRes.json();
62
+ setPokemonDetails(detailsData);
63
+ }
64
+
65
+ // Update background color based on primary type if available
66
+ if (detailsData && detailsData.types && detailsData.types.length > 0) {
67
+ const primaryType = detailsData.types[0];
68
+ if (typeof window !== 'undefined') {
69
+ const cssVar = `--type-${primaryType.toLowerCase()}`;
70
+ const hexColor = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
71
+ if (hexColor && particlesRef.current) {
72
+ particlesRef.current.setAccentColor(hexColor);
73
+ }
74
+ }
75
+ }
76
+
77
+ setStatus('success');
78
+
79
+ if (predictData.confidence > 0.95 && typeof window !== 'undefined') {
80
+ import('canvas-confetti').then((confetti) => {
81
+ confetti.default({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
82
+ });
83
+ }
84
+
85
+ } catch (err) {
86
+ console.error(err);
87
+ setStatus('idle');
88
+ setErrorMessage(err.message || 'An unexpected error occurred.');
89
+ }
90
+ };
91
+
92
+ const handleReset = () => {
93
+ setStatus('idle');
94
+ setPredictionData(null);
95
+ setPokemonDetails(null);
96
+ setSelectedImage(null);
97
+ setErrorMessage('');
98
+ if (particlesRef.current) {
99
+ particlesRef.current.setAccentColor(null);
100
+ }
101
+ };
102
+
103
+ return (
104
+ <main className="main-layout">
105
+ <ParticlesBg ref={particlesRef} />
106
+
107
+ {status === 'success' && predictionData ? (
108
+ <div style={{ gridColumn: '1 / -1', width: '100%', display: 'flex', justifyContent: 'center' }}>
109
+ <ResultCard
110
+ predictionData={predictionData}
111
+ pokemonDetails={pokemonDetails}
112
+ onReset={handleReset}
113
+ />
114
+ </div>
115
+ ) : (
116
+ <>
117
+ <div className="hero-column">
118
+ <Hero />
119
+ </div>
120
+ <div className="upload-column" style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
121
+ <UploadZone
122
+ status={status}
123
+ selectedImage={selectedImage}
124
+ onImageSelect={handleImageSelect}
125
+ onSubmit={handlePredict}
126
+ errorMessage={errorMessage}
127
+ />
128
+ </div>
129
+ </>
130
+ )}
131
+
132
+ {status === 'loading' && <LoadingOverlay />}
133
+ </main>
134
+ );
135
+ }
frontend/src/app/page.module.css ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page {
2
+ --background: #fafafa;
3
+ --foreground: #fff;
4
+
5
+ --text-primary: #000;
6
+ --text-secondary: #666;
7
+
8
+ --button-primary-hover: #383838;
9
+ --button-secondary-hover: #f2f2f2;
10
+ --button-secondary-border: #ebebeb;
11
+
12
+ display: flex;
13
+ flex: 1;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ font-family: var(--font-geist-sans);
18
+ background-color: var(--background);
19
+ }
20
+
21
+ .main {
22
+ display: flex;
23
+ flex: 1;
24
+ width: 100%;
25
+ max-width: 800px;
26
+ flex-direction: column;
27
+ align-items: flex-start;
28
+ justify-content: space-between;
29
+ background-color: var(--foreground);
30
+ padding: 120px 60px;
31
+ }
32
+
33
+ .intro {
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: flex-start;
37
+ text-align: left;
38
+ gap: 24px;
39
+ }
40
+
41
+ .intro h1 {
42
+ max-width: 320px;
43
+ font-size: 40px;
44
+ font-weight: 600;
45
+ line-height: 48px;
46
+ letter-spacing: -2.4px;
47
+ text-wrap: balance;
48
+ color: var(--text-primary);
49
+ }
50
+
51
+ .intro p {
52
+ max-width: 440px;
53
+ font-size: 18px;
54
+ line-height: 32px;
55
+ text-wrap: balance;
56
+ color: var(--text-secondary);
57
+ }
58
+
59
+ .intro a {
60
+ font-weight: 500;
61
+ color: var(--text-primary);
62
+ }
63
+
64
+ .ctas {
65
+ display: flex;
66
+ flex-direction: row;
67
+ width: 100%;
68
+ max-width: 440px;
69
+ gap: 16px;
70
+ font-size: 14px;
71
+ }
72
+
73
+ .ctas a {
74
+ display: flex;
75
+ justify-content: center;
76
+ align-items: center;
77
+ height: 40px;
78
+ padding: 0 16px;
79
+ border-radius: 128px;
80
+ border: 1px solid transparent;
81
+ transition: 0.2s;
82
+ cursor: pointer;
83
+ width: fit-content;
84
+ font-weight: 500;
85
+ }
86
+
87
+ a.primary {
88
+ background: var(--text-primary);
89
+ color: var(--background);
90
+ gap: 8px;
91
+ }
92
+
93
+ a.secondary {
94
+ border-color: var(--button-secondary-border);
95
+ }
96
+
97
+ /* Enable hover only on non-touch devices */
98
+ @media (hover: hover) and (pointer: fine) {
99
+ a.primary:hover {
100
+ background: var(--button-primary-hover);
101
+ border-color: transparent;
102
+ }
103
+
104
+ a.secondary:hover {
105
+ background: var(--button-secondary-hover);
106
+ border-color: transparent;
107
+ }
108
+ }
109
+
110
+ @media (max-width: 600px) {
111
+ .main {
112
+ padding: 48px 24px;
113
+ }
114
+
115
+ .intro {
116
+ gap: 16px;
117
+ }
118
+
119
+ .intro h1 {
120
+ font-size: 32px;
121
+ line-height: 40px;
122
+ letter-spacing: -1.92px;
123
+ }
124
+ }
125
+
126
+ @media (prefers-color-scheme: dark) {
127
+ .logo {
128
+ filter: invert();
129
+ }
130
+
131
+ .page {
132
+ --background: #000;
133
+ --foreground: #000;
134
+
135
+ --text-primary: #ededed;
136
+ --text-secondary: #999;
137
+
138
+ --button-primary-hover: #ccc;
139
+ --button-secondary-hover: #1a1a1a;
140
+ --button-secondary-border: #1a1a1a;
141
+ }
142
+ }
frontend/src/app/research/page.js ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import styles from './page.module.css';
2
+ import Link from 'next/link';
3
+ import PokedexNetDiagram from '../../components/PokedexNetDiagram';
4
+ import FontScaler from '../../components/FontScaler';
5
+
6
+ export const metadata = {
7
+ title: 'Research Paper — Pokédex AI',
8
+ description: 'Technical deep dive into the ResNet-18 model architecture and training process for classifying 1025 Pokémon silhouettes.',
9
+ };
10
+
11
+ export default function ResearchPaper() {
12
+ return (
13
+ <div className={styles.container}>
14
+ <article className={styles.article}>
15
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
16
+ <Link href="/" className={styles.backLink} style={{ marginBottom: 0 }}>
17
+ ← Back to Pokédex
18
+ </Link>
19
+ <FontScaler />
20
+ </div>
21
+
22
+ <header className={styles.header}>
23
+ <div className={styles.subtitle}>Technical Document</div>
24
+ <h1 className={styles.title}>Identifying 1,025 Pokémon Silhouettes via Deep Learning</h1>
25
+ </header>
26
+
27
+ <section className={styles.section}>
28
+ <h2 className={styles.sectionTitle}>Abstract</h2>
29
+ <p className={styles.paragraph}>
30
+ This paper introduces PokedexNet, a modified ResNet-18 architecture trained to classify 1,025 Pokémon species from binary silhouettes alone — a particularly demanding instance of fine-grained image classification in which the complete absence of color and texture forces the model to rely strictly on shape geometry and spatial topology. The training corpus comprises 32,115 heterogeneous samples drawn from the PokéAPI repository and processed through a deterministic pipeline covering silhouette extraction, spatial normalization, and ROI segmentation. To encourage robustness to partial occlusion, stochastic data augmentation with Random Erasing was configured to insert white blocks rather than dark ones — a deliberate design choice that compels the network to identify species from morphological fragments rather than whole-body contours.
31
+ </p>
32
+ <p className={styles.paragraph}>
33
+ The model was trained from scratch, without ImageNet pretraining, to prevent negative transfer from photographic feature representations incompatible with the binary input domain. Performance was evaluated across five independent runs with distinct random seeds. Mean Top-1 accuracy reached <strong>80.87% (±0.53%)</strong>, Top-5 accuracy <strong>83.93% (±0.48%)</strong>, and Macro F1-Score <strong>0.852 (±0.005)</strong>. The narrow gap between Top-1 and Top-5 scores reflects what this work terms a <em>morphological ceiling</em>: pairs such as Voltorb and Electrode, whose 2D orthographic projections are geometrically identical, define a hard theoretical limit for pure silhouette recognition. Out-of-distribution experiments further revealed that the learned feature representations encode anatomically coherent abstractions, producing structured predictions even for unseen geometries.
34
+ </p>
35
+ </section>
36
+
37
+ <section className={styles.section}>
38
+ <h2 className={styles.sectionTitle}>1. Introduction</h2>
39
+ <p className={styles.paragraph}>
40
+ The challenge of identifying entities from their outlines — popularized by the &quot;Who&apos;s That Pokémon?&quot; segment — turns out to be a genuinely non-trivial computer vision problem. Classifying 1,025 distinct classes using only binary silhouettes is an extreme instance of fine-grained image classification: by stripping away color and texture entirely, conventional recognition heuristics become unavailable, and the model must operate on geometry and spatial topology alone.
41
+ </p>
42
+ <p className={styles.paragraph}>
43
+ The problem is further complicated by the breadth of the dataset. Later generations of the franchise introduced species whose body plans are structurally very similar to earlier ones, creating cases where taxonomically distinct species project onto nearly identical 2D contours under orthographic projection. Handling this degree of inter-class similarity requires architectures capable of extracting subtle morphological cues that would be easy to overlook — the kind of hierarchical feature learning that convolutional neural networks are well suited for.
44
+ </p>
45
+ </section>
46
+
47
+ <section className={styles.section}>
48
+ <h2 className={styles.sectionTitle}>2. Data Collection and Preprocessing</h2>
49
+ <p className={styles.paragraph}>
50
+ The dataset was assembled from the official PokéAPI asset repository, cloned via Git to preserve metadata integrity and ensure that each sample could be traced back to its national Pokédex ID. The final corpus contains 32,115 images spanning a wide stylistic range, from the pixel art sprites of the original Game Boy games to the high-resolution 3D renders used in contemporary titles. This breadth was deliberate: training on a single visual generation would bias the model toward that generation&apos;s specific proportions and artistic conventions, limiting its ability to generalize across the franchise&apos;s full visual history.
51
+ </p>
52
+
53
+ <h3 className={styles.subsectionTitle}>2.1. Deterministic Preprocessing Pipeline</h3>
54
+ <p className={styles.paragraph}>
55
+ Each sample passes through a fixed sequence of transformations before training, designed to eliminate spatial variance and reduce the input to pure shape information:
56
+ </p>
57
+ <ul className={styles.list}>
58
+ <li className={styles.listItem}>
59
+ <strong>Silhouette extraction:</strong> Source images in RGBA format are segmented via their alpha channel. Pixels with opacity above 50% are mapped to black (0); all others become white (255), producing a clean binary mask.
60
+ </li>
61
+ <li className={styles.listItem}>
62
+ <strong>ROI cropping:</strong> The bounding box of the active pixels is computed dynamically and the image is tightly cropped to it, removing irrelevant empty margins.
63
+ </li>
64
+ <li className={styles.listItem}>
65
+ <strong>Normalization and padding:</strong> To avoid distorting body proportions, the cropped mask is centered on a square canvas with 20% padding on all sides. This ensures that extremities — wings, tails, antennae — stay clear of the tensor boundary.
66
+ </li>
67
+ <li className={styles.listItem}>
68
+ <strong>Tensorization:</strong> The final result is converted to a single-channel (grayscale) tensor at 128×128 pixels and normalized to support gradient convergence during backpropagation.
69
+ </li>
70
+ </ul>
71
+
72
+ <figure className={styles.figure}>
73
+ {/* eslint-disable-next-line @next/next/no-img-element */}
74
+ <img src="/static/images/pipeline_visual.png" alt="Deterministic Pipeline" className={styles.image} />
75
+ <figcaption className={styles.caption}>Figure 1: Deterministic preprocessing pipeline illustrating spatial normalization from the original RGB image to the final 128×128 input tensor.</figcaption>
76
+ </figure>
77
+
78
+ <h3 className={styles.subsectionTitle}>2.2. Heuristic Background Removal for Open-Domain Inference</h3>
79
+ <p className={styles.paragraph}>
80
+ Inference in the wild introduces a complication: user-submitted images rarely have a usable alpha channel. To address this, a heuristic routing module was developed that identifies the likely source of an image and applies a dedicated extraction strategy, ensuring the network always receives a topologically consistent binary tensor regardless of background noise.
81
+ </p>
82
+ <p className={styles.paragraph}>
83
+ Each image is first converted to both HSV and grayscale color spaces. Pixel density in predefined spectral ranges is then used to classify the image source at runtime. Three chromatic profiles were identified: (i) vivid red backgrounds characteristic of classic anime broadcast frames; (ii) high-saturation blue screens common in modern media assets; and (iii) irregular backgrounds associated with third-party platforms, typically featuring gray tones or visible watermarks.
84
+ </p>
85
+ <p className={styles.paragraph}>
86
+ Once the source is identified, samples lacking a viable alpha channel are processed through color masking and adaptive thresholding to isolate the subject from the background. The resulting binary mask is then refined using iterative morphological Opening and Closing operations with 3×3 and 5×5 structuring kernels, which remove spurious noise from the background and fill gaps within the silhouette interior.
87
+ </p>
88
+ <p className={styles.paragraph}>
89
+ Finally, contour detection is run over the cleaned mask. Only the largest connected region is retained; peripheral artifacts, text overlays, and watermarks are discarded. The resulting mask is then passed through the same bounding-box centering and 20% padding step used during training, producing the input tensor expected by the classifier.
90
+ </p>
91
+ </section>
92
+
93
+ <section className={styles.section}>
94
+ <h2 className={styles.sectionTitle}>3. Stochastic Data Augmentation</h2>
95
+ <p className={styles.paragraph}>
96
+ To reduce overfitting and prevent the network from memorizing the exact pixel distributions in the training set, a stochastic augmentation pipeline was applied during training. The objective was to push the model toward learning underlying geometric features rather than specific spatial configurations.
97
+ </p>
98
+ <p className={styles.paragraph}>
99
+ Three augmentation strategies were used. First, RandomResizedCrop randomly scales and crops each image to 128×128 pixels with a scale factor between 0.7 and 1.0, simulating variation in distance and framing. Second, RandomHorizontalFlip mirrors images with 50% probability, doubling the effective pose diversity by exploiting the bilateral symmetry — or deliberate asymmetry — of each species&apos; body plan. Third, and most consequentially, Random Erasing was applied stochastically to 50% of training samples.
100
+ </p>
101
+ <p className={styles.paragraph}>
102
+ A key implementation decision was setting <span className="mono">value=1.0</span> in Random Erasing, which fills erased regions with white rather than the default black. In a binary domain — black silhouettes on white backgrounds — dark occlusion blocks would artificially expand the apparent mass of the subject, creating a spurious signal. White blocks instead simulate morphological subtraction: they excise limbs, appendages, and tail segments, forcing the classifier to identify species from whatever structural fragments remain. This proved to be the single most impactful contributor to final accuracy, pushing the network away from whole-contour matching and toward learning local morphological correlations.
103
+ </p>
104
+
105
+ <figure className={styles.figure}>
106
+ {/* eslint-disable-next-line @next/next/no-img-element */}
107
+ <img src="/static/images/augmentation_visual.png" alt="Data Augmentation Pipeline" className={styles.image} />
108
+ <figcaption className={styles.caption}>Figure 2: Augmentation strategies applied during training. Mutations 3 and 4 illustrate Random Erasing with white blocks, compelling the network to recognize species under severe partial occlusion.</figcaption>
109
+ </figure>
110
+ </section>
111
+
112
+ <section className={styles.section}>
113
+ <h2 className={styles.sectionTitle}>4. Model Architecture</h2>
114
+ <p className={styles.paragraph}>
115
+ ResNet-18 was selected as the backbone architecture on the basis of its balance between representational depth and computational cost. For a silhouette classification task stripped of color and texture, a moderately sized network offers a more favorable overfitting profile than deeper alternatives, while still providing sufficient capacity to discriminate fine-grained morphological variation.
116
+ </p>
117
+
118
+ <h3 className={styles.subsectionTitle}>4.1. Modifications</h3>
119
+ <p className={styles.paragraph}>
120
+ The standard ResNet-18 was adapted in three respects to fit the constraints of this domain, forming the architecture designated here as PokedexNet.
121
+ </p>
122
+ <ul className={styles.list}>
123
+ <li className={styles.listItem}>
124
+ The first convolutional layer (<span className="mono">conv1</span>) was reconfigured to accept a single grayscale input channel. This eliminates redundant parameterization at the entry point and focuses gradient flow on the binary feature maps from the outset.
125
+ </li>
126
+ <li className={styles.listItem}>
127
+ The final fully connected layer (<span className="mono">fc</span>) was resized to produce 1,025 output logits, one per species and morphological variant in the dataset.
128
+ </li>
129
+ <li className={styles.listItem}>
130
+ Residual skip connections were preserved throughout. These are essential in a setting where the discriminating signal is subtle: they allow gradients to propagate cleanly during backpropagation and keep the network focused on modeling fine morphological differences rather than fighting vanishing gradients.
131
+ </li>
132
+ </ul>
133
+
134
+ <h3 className={styles.subsectionTitle}>4.2. Training from Scratch</h3>
135
+ <p className={styles.paragraph}>
136
+ All weights were initialized randomly rather than starting from ImageNet-pretrained parameters. The reasoning is straightforward: pretrained ResNet filters are optimized for detecting illumination gradients, fine textures, and color transitions in natural photographs. None of those features exist in a binary silhouette. Adapting such filters to a high-contrast, single-channel binary domain would require the network to spend significant capacity unlearning representations that are actively counterproductive here.
137
+ </p>
138
+ <p className={styles.paragraph}>
139
+ Starting from random initialization forces the convolutional layers to build a feature hierarchy appropriate to the domain from the ground up. Early layers specialize in detecting hard edges, concavities, and local curvature; deeper layers abstract over global topology and structural proportions. The result is a latent space grounded entirely in shape geometry, without contamination from photographic priors.
140
+ </p>
141
+
142
+ <figure className={styles.figure}>
143
+ <PokedexNetDiagram />
144
+ <figcaption className={styles.caption}>Figure 3: Topological structure of the PokedexNet model. A modified ResNet-18 featuring a single-channel grayscale input convolution and a custom 1,025-class fully connected head.</figcaption>
145
+ </figure>
146
+ </section>
147
+
148
+ <section className={styles.section}>
149
+ <h2 className={styles.sectionTitle}>5. Experimental Setup and Training Dynamics</h2>
150
+
151
+ <h3 className={styles.subsectionTitle}>5.1. Hardware</h3>
152
+ <p className={styles.paragraph}>
153
+ Training was conducted on a system equipped with an NVIDIA RTX 4050 (65W, 6 GB VRAM), an Intel Core 7 240H processor, and 16 GB of DDR5 RAM running at 5,600 MT/s.
154
+ </p>
155
+
156
+ <h3 className={styles.subsectionTitle}>5.2. Training Configuration</h3>
157
+ <p className={styles.paragraph}>
158
+ Automatic Mixed Precision (AMP) was used throughout to reduce VRAM consumption and increase throughput, performing operations in reduced precision wherever numerical stability permitted.
159
+ </p>
160
+ <p className={styles.paragraph}>
161
+ Optimization was handled by Adam with a ReduceLROnPlateau learning rate scheduler monitoring validation loss. The scheduler was configured with a patience of five epochs: if validation loss did not decrease over five consecutive epochs, the learning rate was reduced. This dynamic decay strategy allowed the model to escape convergence plateaus without the instability that comes from aggressive fixed-schedule decay.
162
+ </p>
163
+
164
+ <h3 className={styles.subsectionTitle}>5.3. Multi-Run Variance Analysis and Early Stopping</h3>
165
+ <p className={styles.paragraph}>
166
+ To obtain statistically reliable results, training was repeated five times with different random seeds: 3, 7, 25, 121, and 255. Each seed controls both weight initialization and data partitioning, ensuring that runs are genuinely independent while remaining fully reproducible. A maximum of 100 epochs was allowed per run. Early Stopping was applied with a patience of 15 epochs: training was halted if validation loss showed no improvement for 15 consecutive epochs, preserving the checkpoint with the best generalization performance.
167
+ </p>
168
+ <p className={styles.paragraph}>
169
+ Across all runs, loss and accuracy converge to stable values with consistent trajectories, indicating that the training dynamics are not sensitive to the choice of seed.
170
+ </p>
171
+
172
+ <figure className={styles.figure}>
173
+ <div className={styles.oodGrid}>
174
+ <div className={styles.gridItem}>
175
+ <span className={styles.gridItemLabel}>Seed 3</span>
176
+ {/* eslint-disable-next-line @next/next/no-img-element */}
177
+ <img src="/static/images/training_curves_multirun_seed_3.png" alt="Training Curve Seed 3" className={styles.oodGridImage} />
178
+ </div>
179
+ <div className={styles.gridItem}>
180
+ <span className={styles.gridItemLabel}>Seed 7</span>
181
+ {/* eslint-disable-next-line @next/next/no-img-element */}
182
+ <img src="/static/images/training_curves_multirun_seed_7.png" alt="Training Curve Seed 7" className={styles.oodGridImage} />
183
+ </div>
184
+ <div className={styles.gridItem}>
185
+ <span className={styles.gridItemLabel}>Seed 25</span>
186
+ {/* eslint-disable-next-line @next/next/no-img-element */}
187
+ <img src="/static/images/training_curves_multirun_seed_25.png" alt="Training Curve Seed 25" className={styles.oodGridImage} />
188
+ </div>
189
+ <div className={styles.gridItem}>
190
+ <span className={styles.gridItemLabel}>Seed 121</span>
191
+ {/* eslint-disable-next-line @next/next/no-img-element */}
192
+ <img src="/static/images/training_curves_multirun_seed_121.png" alt="Training Curve Seed 121" className={styles.oodGridImage} />
193
+ </div>
194
+ <div className={styles.gridItem}>
195
+ <span className={styles.gridItemLabel}>Seed 255</span>
196
+ {/* eslint-disable-next-line @next/next/no-img-element */}
197
+ <img src="/static/images/training_curves_multirun_seed_255.png" alt="Training Curve Seed 255" className={styles.oodGridImage} />
198
+ </div>
199
+ </div>
200
+ <figcaption className={styles.caption} style={{ marginTop: '1rem' }}>Figure 4: Representative loss and accuracy curves showing stable convergence and consistent agreement between training and validation sets across all five independent seeds.</figcaption>
201
+ </figure>
202
+ </section>
203
+
204
+ <section className={styles.section}>
205
+ <h2 className={styles.sectionTitle}>6. Results and Performance Analysis</h2>
206
+ <p className={styles.paragraph}>
207
+ Results were aggregated across five independent runs, using a 90/10 train/validation split. Table 1 summarizes the per-seed metrics and their aggregate statistics.
208
+ </p>
209
+ <p className={styles.paragraph}>
210
+ Mean Top-1 accuracy reached <strong>80.87% (±0.53%)</strong>, mean Top-5 accuracy <strong>83.93% (±0.48%)</strong>, and mean Macro F1-Score <strong>0.852 (±0.005)</strong>. For a 1,025-class task in which the input contains no color or texture whatsoever, these figures represent strong performance. The low standard deviation across seeds confirms that results are not an artifact of a particularly favorable initialization — the model converges to a consistent solution regardless of starting conditions.
211
+ </p>
212
+
213
+ <p className={styles.tableTitle}>Table 1: Performance metrics per initialization seed.</p>
214
+ <div className={styles.tableWrapper}>
215
+ <table className={styles.table}>
216
+ <thead>
217
+ <tr>
218
+ <th>Seed</th>
219
+ <th>Val Loss</th>
220
+ <th>Top-1 Acc (%)</th>
221
+ <th>Top-5 Acc (%)</th>
222
+ <th>Macro F1</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ <tr><td>3</td><td>1.0479</td><td>80.82</td><td>83.90</td><td>0.848</td></tr>
227
+ <tr><td>7</td><td>1.0093</td><td>79.94</td><td>83.18</td><td>0.850</td></tr>
228
+ <tr><td>25</td><td>0.9864</td><td>81.56</td><td>84.55</td><td>0.854</td></tr>
229
+ <tr><td>121</td><td>1.0516</td><td>80.91</td><td>83.68</td><td>0.862</td></tr>
230
+ <tr><td>255</td><td>1.0098</td><td>81.10</td><td>84.34</td><td>0.847</td></tr>
231
+ <tr className={styles.summaryRow}><td>Mean</td><td>1.0210</td><td>80.87</td><td>83.93</td><td>0.852</td></tr>
232
+ <tr className={styles.summaryRow}><td>Std. Dev.</td><td>0.0278</td><td>0.53</td><td>0.48</td><td>0.005</td></tr>
233
+ </tbody>
234
+ </table>
235
+ </div>
236
+
237
+ <p className={styles.paragraph}>
238
+ The consistency of the Macro F1 score is particularly noteworthy. Because Macro F1 weights each class equally regardless of sample frequency, a high value here indicates that the model generalizes well even to morphologically rare species with fewer training examples — not just to the most common body plans.
239
+ </p>
240
+ <p className={styles.paragraph}>
241
+ Beyond the standard deviation, computing the Standard Error of the Mean (SEM) yields a 95% confidence interval for the true Top-1 accuracy: <strong>80.87% ± 0.66%</strong>. The tightness of this interval rules out the possibility that the observed performance is a lucky outcome of any particular training run.
242
+ </p>
243
+
244
+ <h3 className={styles.subsectionTitle}>6.1. Confusion Boundaries and the Morphological Ceiling</h3>
245
+ <p className={styles.paragraph}>
246
+ The approximately 3.02-percentage-point gap between Top-1 and Top-5 accuracy points to what this work terms a <em>morphological ceiling</em> — a hard performance limit inherent to the problem itself rather than to any shortcoming of the model. When a three-dimensional body is projected onto a two-dimensional binary plane, some discriminating information is irreversibly lost, and no amount of training can recover it.
247
+ </p>
248
+ <p className={styles.paragraph}>
249
+ The clearest illustration of this limit is the Voltorb–Electrode pair. Both species produce a perfect sphere under orthographic 2D projection. When either is presented, the model distributes probability mass roughly equally between the two classes, because there is literally no geometric feature that distinguishes them in this representation — the extractor has reached maximum entropy. Notably, this confusion is entirely consistent across all five seeds and all training runs.
250
+ </p>
251
+ <p className={styles.paragraph}>
252
+ Rather than being a failure mode, this behavior validates the preprocessing pipeline. The fact that the model&apos;s errors are concentrated precisely on geometrically degenerate cases — and not on pairs that merely look similar but do have distinguishing contours — confirms that the classifier is operating on pure morphology. There is no texture leakage, no background artifact, no contextual shortcut. The model has learned what it was designed to learn, and the ceiling it hits is a property of the data, not of the architecture.
253
+ </p>
254
+
255
+ <h3 className={styles.subsectionTitle}>6.2. Multi-Run Validation of Geometric Feature Learning</h3>
256
+ <p className={styles.paragraph}>
257
+ Comparing confusion matrices across all five seeds reinforces the same conclusion. Despite the stochastic differences between runs, the error patterns are remarkably stable: the same pairs of species are confused, with similar probability distributions, regardless of initialization.
258
+ </p>
259
+ <p className={styles.paragraph}>
260
+ This cross-seed consistency is strong evidence that the model&apos;s learned representations reflect genuine geometric structure in the data rather than idiosyncrasies of any particular training trajectory. The latent space that emerges from training is organized by morphological similarity — and where that similarity becomes topologically indistinguishable, the model reaches a principled limit that no further training can push past.
261
+ </p>
262
+
263
+ <figure className={styles.figure}>
264
+ {/* eslint-disable-next-line @next/next/no-img-element */}
265
+ <img src="/static/images/mosaic_confusion_extreme_cases.png" alt="Confusion Matrix Extreme Cases" className={styles.image} />
266
+ <figcaption className={styles.caption}>Figure 5: Representative subset of the confusion matrix highlighting cases of extreme morphometric overlap. The consistently balanced error distribution between geometrically identical spherical projections illustrates the hard theoretical limit of contour-based visual recognition.</figcaption>
267
+ </figure>
268
+ </section>
269
+
270
+ <section className={styles.section}>
271
+ <h2 className={styles.sectionTitle}>7. Discussion: Out-of-Distribution Behavior</h2>
272
+ <p className={styles.paragraph}>
273
+ Strong in-distribution performance tells only part of the story. Like all closed-set softmax classifiers, PokedexNet has no native mechanism to abstain or flag uncertainty when presented with an input outside its training distribution. To probe how the learned representations respond to an entirely foreign geometry, a silhouette from a different franchise — referred to here as &quot;Rhamphomon&quot; (see Figure 6) — was submitted to the model and the resulting probability distributions were examined.
274
+ </p>
275
+
276
+ <figure className={styles.figure} style={{ marginTop: '1rem', marginBottom: '2.5rem' }}>
277
+ {/* eslint-disable-next-line @next/next/no-img-element */}
278
+ <img src="/static/images/digimon.png" alt="Rhamphomon (Out-of-Distribution Input)" className={styles.image} style={{ maxWidth: '300px' }} />
279
+ <figcaption className={styles.caption} style={{ marginTop: '1rem' }}>Figure 6: Rhamphomon, an Out-of-Distribution input used to test the model&apos;s structural abstraction capabilities.</figcaption>
280
+ </figure>
281
+ <p className={styles.paragraph}>
282
+ Since the model must assign probability to some class, it maps the unknown geometry onto the nearest regions of its learned latent space. What is interesting is <em>how</em> it does so. Rather than collapsing to a single arbitrary prediction, the model identifies anatomically coherent matches: the crouching posture and appendage structure of the intruding silhouette draw probability toward species that share those same structural features.
283
+ </p>
284
+ <p className={styles.paragraph}>
285
+ The pattern varies across seeds, as shown in Figure 7. Seeds 3 and 121 converge on Crobat as the top prediction, with confidence as high as 66.7%. Seeds 7, 25, and 255 shift the probability mass toward Purrloin, with flatter and more competitive distributions. The fact that the top predictions alternate between a crouching feline and a winged bat — structurally quite different species — reflects the ambiguity of the input: different random initializations emphasize different anatomical fragments of the same silhouette.
286
+ </p>
287
+ <p className={styles.paragraph}>
288
+ Across all seeds, aerodynamically proportioned species such as Talonflame, Aerodactyl, and Kilowattrel appear consistently near the top of the confidence ranking. This is not coincidental: it suggests that the convolutional filters have internalized structural concepts like wingspan geometry and streamlined body ratios at a level of abstraction that transfers, at least partially, to novel organisms.
289
+ </p>
290
+ <p className={styles.paragraph}>
291
+ The seed-dependent instability of the top prediction is itself informative. A confident, seed-stable prediction is a sign of genuine recognition; an unstable one — where the winning class flips depending on initialization — is a natural indicator of out-of-distribution input. This emergent behavior suggests a potential path toward lightweight anomaly detection without adding any explicit rejection mechanism to the classifier.
292
+ </p>
293
+
294
+ <figure className={styles.figure}>
295
+ <div className={styles.oodGrid}>
296
+ <div className={styles.gridItem}>
297
+ <span className={styles.gridItemLabel}>Seed 3</span>
298
+ {/* eslint-disable-next-line @next/next/no-img-element */}
299
+ <img src="/static/images/softmax_ood_dark_en_best_model_multirun_seed_3.pth.png" alt="OOD Softmax Seed 3" className={styles.oodGridImage} />
300
+ </div>
301
+ <div className={styles.gridItem}>
302
+ <span className={styles.gridItemLabel}>Seed 7</span>
303
+ {/* eslint-disable-next-line @next/next/no-img-element */}
304
+ <img src="/static/images/softmax_ood_dark_en_best_model_multirun_seed_7.pth.png" alt="OOD Softmax Seed 7" className={styles.oodGridImage} />
305
+ </div>
306
+ <div className={styles.gridItem}>
307
+ <span className={styles.gridItemLabel}>Seed 25</span>
308
+ {/* eslint-disable-next-line @next/next/no-img-element */}
309
+ <img src="/static/images/softmax_ood_dark_en_best_model_multirun_seed_25.pth.png" alt="OOD Softmax Seed 25" className={styles.oodGridImage} />
310
+ </div>
311
+ <div className={styles.gridItem}>
312
+ <span className={styles.gridItemLabel}>Seed 121</span>
313
+ {/* eslint-disable-next-line @next/next/no-img-element */}
314
+ <img src="/static/images/softmax_ood_dark_en_best_model_multirun_seed_121.pth.png" alt="OOD Softmax Seed 121" className={styles.oodGridImage} />
315
+ </div>
316
+ <div className={styles.gridItem}>
317
+ <span className={styles.gridItemLabel}>Seed 255</span>
318
+ {/* eslint-disable-next-line @next/next/no-img-element */}
319
+ <img src="/static/images/softmax_ood_dark_en_best_model_multirun_seed_255.pth.png" alt="OOD Softmax Seed 255" className={styles.oodGridImage} />
320
+ </div>
321
+ </div>
322
+ <figcaption className={styles.caption} style={{ marginTop: '1rem' }}>Figure 7: Softmax probability distributions produced by classifying an OOD geometric input across all five seeds. Confidence is split among species sharing aerodynamic structural traits, and the top prediction varies between seeds — consistent with the behavior expected from an input that lies outside the training distribution.</figcaption>
323
+ </figure>
324
+ </section>
325
+
326
+ <section className={styles.section}>
327
+ <h2 className={styles.sectionTitle}>8. Conclusion</h2>
328
+ <p className={styles.paragraph}>
329
+ PokedexNet demonstrates that a modestly sized convolutional network, trained from scratch on binary silhouettes, can achieve over 80.8% Top-1 accuracy across 1,025 classes — relying entirely on geometric shape, without any color, texture, or contextual information. The stability of results across five independent seeds confirms that this is a robust outcome, not a product of favorable initialization. The consistent Macro F1 score shows the model generalizes equitably across rare and common body plans alike.
330
+ </p>
331
+ <p className={styles.paragraph}>
332
+ The performance ceiling the model encounters is not a weakness of the architecture but a fundamental property of the problem: some species are geometrically indistinguishable under binary 2D projection, and no classifier can do better than chance on those pairs without additional visual information. The fact that errors cluster precisely on these degenerate cases validates both the preprocessing pipeline and the geometric focus of the learned representations.
333
+ </p>
334
+ <p className={styles.paragraph}>
335
+ Out-of-distribution experiments revealed that the model&apos;s anatomical abstractions are coherent enough to produce structured, morphologically grounded predictions even for unseen organisms. The cross-seed instability of OOD predictions offers a promising passive signal for anomaly detection — a direction worth exploring in future work alongside explicit open-set recognition mechanisms.
336
+ </p>
337
+ </section>
338
+
339
+ <section className={styles.section}>
340
+ <h2 className={styles.sectionTitle}>List of Figures</h2>
341
+ <ul className={styles.list}>
342
+ <li className={styles.listItem}><strong>Figure 1:</strong> Deterministic preprocessing pipeline illustrating spatial normalization from the original RGB image to the final 128×128 input tensor.</li>
343
+ <li className={styles.listItem}><strong>Figure 2:</strong> Augmentation strategies applied during training. Mutations 3 and 4 illustrate Random Erasing with white blocks, compelling the network to recognize species under severe partial occlusion.</li>
344
+ <li className={styles.listItem}><strong>Figure 3:</strong> Topological structure of the PokedexNet model. A modified ResNet-18 featuring a single-channel grayscale input convolution and a custom 1,025-class fully connected head.</li>
345
+ <li className={styles.listItem}><strong>Figure 4:</strong> Representative loss and accuracy curves showing stable convergence and consistent agreement between training and validation sets across all five independent seeds.</li>
346
+ <li className={styles.listItem}><strong>Figure 5:</strong> Representative subset of the confusion matrix highlighting cases of extreme morphometric overlap. The consistently balanced error distribution between geometrically identical spherical projections illustrates the hard theoretical limit of contour-based visual recognition.</li>
347
+ <li className={styles.listItem}><strong>Figure 6:</strong> Rhamphomon, an Out-of-Distribution input used to test the model&apos;s structural abstraction capabilities.</li>
348
+ <li className={styles.listItem}><strong>Figure 7:</strong> Softmax probability distributions produced by classifying an OOD geometric input across all five seeds. Confidence is split among species sharing aerodynamic structural traits, and the top prediction varies between seeds.</li>
349
+ </ul>
350
+ </section>
351
+
352
+ </article>
353
+ </div>
354
+ );
355
+ }
frontend/src/app/research/page.module.css ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .container {
2
+ min-height: 100vh;
3
+ padding: 6rem 2rem 4rem;
4
+ position: relative;
5
+ z-index: 1;
6
+ display: flex;
7
+ justify-content: center;
8
+ }
9
+
10
+ .article {
11
+ width: clamp(320px, 75%, 1265px);
12
+ background: var(--bg-glass);
13
+ backdrop-filter: blur(24px) saturate(160%);
14
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
15
+ border: 1px solid var(--border-glass);
16
+ border-radius: 32px;
17
+ padding: 3rem 4rem;
18
+ box-shadow:
19
+ inset 0 1px 1px rgba(255, 255, 255, 0.2),
20
+ 0 20px 40px rgba(0, 0, 0, 0.4);
21
+ animation: fadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
22
+ }
23
+
24
+ .header {
25
+ text-align: center;
26
+ margin-bottom: 4rem;
27
+ border-bottom: 1px solid var(--border-glass);
28
+ padding-bottom: 2rem;
29
+ }
30
+
31
+ .title {
32
+ font-size: clamp(1.5rem, 4vw, 3rem);
33
+ font-weight: 800;
34
+ line-height: 1.2;
35
+ margin-bottom: 1rem;
36
+ color: var(--text-primary);
37
+ background: linear-gradient(135deg, #ffffff 30%, #c084fc);
38
+ -webkit-background-clip: text;
39
+ background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ letter-spacing: -1px;
42
+ word-wrap: break-word;
43
+ overflow-wrap: break-word;
44
+ }
45
+
46
+ .subtitle {
47
+ font-size: clamp(0.85rem, 1.5vw, 1.1rem);
48
+ color: var(--accent-primary);
49
+ font-weight: 600;
50
+ text-transform: uppercase;
51
+ letter-spacing: 2px;
52
+ }
53
+
54
+ .section {
55
+ margin-bottom: 3.5rem;
56
+ }
57
+
58
+ .sectionTitle {
59
+ font-size: clamp(1.25rem, 3vw, 1.75rem);
60
+ font-weight: 700;
61
+ color: var(--text-primary);
62
+ margin-bottom: 1.5rem;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 0.75rem;
66
+ }
67
+
68
+ .sectionTitle::before {
69
+ content: '';
70
+ display: block;
71
+ width: 10px;
72
+ height: 10px;
73
+ min-width: 10px;
74
+ border-radius: 50%;
75
+ background: var(--accent-primary);
76
+ box-shadow: 0 0 15px var(--accent-primary);
77
+ }
78
+
79
+ .subsectionTitle {
80
+ font-size: clamp(1.1rem, 2.5vw, 1.4rem);
81
+ font-weight: 600;
82
+ color: var(--text-primary);
83
+ margin-top: 2.5rem;
84
+ margin-bottom: 1.25rem;
85
+ }
86
+
87
+ .paragraph {
88
+ font-size: calc(clamp(0.95rem, 1.5vw, 1.1rem) * var(--font-scale, 1.2));
89
+ line-height: 1.8;
90
+ color: var(--text-secondary);
91
+ margin-bottom: 1.5rem;
92
+ text-align: justify;
93
+ }
94
+
95
+ .list {
96
+ list-style: none;
97
+ padding-left: 0;
98
+ margin-bottom: 1.5rem;
99
+ }
100
+
101
+ .listItem {
102
+ font-size: calc(clamp(0.95rem, 1.5vw, 1.1rem) * var(--font-scale, 1.2));
103
+ line-height: 1.8;
104
+ color: var(--text-secondary);
105
+ margin-bottom: 0.75rem;
106
+ padding-left: 2rem;
107
+ position: relative;
108
+ }
109
+
110
+ .listItem::before {
111
+ content: '→';
112
+ position: absolute;
113
+ left: 0;
114
+ color: var(--accent-secondary);
115
+ font-weight: bold;
116
+ }
117
+
118
+ .highlight {
119
+ color: var(--accent-primary);
120
+ font-weight: 600;
121
+ }
122
+
123
+ .backLink {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 0.5rem;
127
+ color: var(--text-secondary);
128
+ text-decoration: none;
129
+ font-family: var(--font-body);
130
+ font-weight: 600;
131
+ margin-bottom: 2rem;
132
+ transition: color 0.3s ease;
133
+ }
134
+
135
+ .backLink:hover {
136
+ color: var(--accent-primary);
137
+ }
138
+
139
+ @keyframes fadeIn {
140
+ from {
141
+ opacity: 0;
142
+ transform: translateY(20px);
143
+ }
144
+ to {
145
+ opacity: 1;
146
+ transform: translateY(0);
147
+ }
148
+ }
149
+
150
+ .figure {
151
+ margin: 2.5rem 0;
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: center;
155
+ gap: 1rem;
156
+ }
157
+
158
+ .image {
159
+ max-width: 100%;
160
+ height: auto;
161
+ border-radius: 16px;
162
+ border: 1px solid var(--border-glass);
163
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
164
+ background: rgba(255, 255, 255, 0.02);
165
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
166
+ }
167
+
168
+ .image:hover {
169
+ transform: scale(1.015);
170
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5);
171
+ }
172
+
173
+ .caption {
174
+ font-size: 0.9rem;
175
+ color: var(--text-secondary);
176
+ text-align: center;
177
+ max-width: 85%;
178
+ line-height: 1.5;
179
+ font-style: italic;
180
+ }
181
+
182
+ .tableTitle {
183
+ font-size: calc(clamp(1.1rem, 2vw, 1.25rem) * var(--font-scale, 1.2));
184
+ font-weight: 700;
185
+ color: var(--text-primary);
186
+ text-align: center;
187
+ margin-top: 2.5rem;
188
+ margin-bottom: 1rem;
189
+ letter-spacing: 0.5px;
190
+ }
191
+
192
+ .oodContainer {
193
+ display: flex;
194
+ gap: 20px;
195
+ align-items: center;
196
+ margin-top: 2rem;
197
+ margin-bottom: 1rem;
198
+ }
199
+
200
+ .oodImageSmall {
201
+ width: 30%;
202
+ border-radius: 12px;
203
+ border: 1px solid var(--border-glass);
204
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
205
+ height: auto;
206
+ transition: transform 0.3s ease;
207
+ }
208
+
209
+ .oodImageSmall:hover {
210
+ transform: scale(1.02);
211
+ }
212
+
213
+ .oodImageLarge {
214
+ width: 70%;
215
+ border-radius: 12px;
216
+ border: 1px solid var(--border-glass);
217
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
218
+ height: auto;
219
+ transition: transform 0.3s ease;
220
+ }
221
+
222
+ .oodImageLarge:hover {
223
+ transform: scale(1.015);
224
+ }
225
+
226
+ .tableWrapper {
227
+ width: 100%;
228
+ overflow-x: auto;
229
+ margin: 2rem 0;
230
+ border-radius: 12px;
231
+ border: 1px solid var(--border-glass);
232
+ background: rgba(255, 255, 255, 0.02);
233
+ }
234
+
235
+ .table {
236
+ width: 100%;
237
+ border-collapse: collapse;
238
+ text-align: left;
239
+ font-size: 0.95rem;
240
+ }
241
+
242
+ .table th, .table td {
243
+ padding: 1rem;
244
+ border-bottom: 1px solid var(--border-glass);
245
+ color: var(--text-secondary);
246
+ }
247
+
248
+ .table th {
249
+ background: rgba(255, 255, 255, 0.05);
250
+ color: var(--text-primary);
251
+ font-weight: 600;
252
+ }
253
+
254
+ .table tr:last-child td {
255
+ border-bottom: none;
256
+ }
257
+
258
+ .summaryRow td {
259
+ font-weight: 700;
260
+ color: var(--text-primary);
261
+ background: rgba(255, 255, 255, 0.03);
262
+ }
263
+
264
+ .oodGrid {
265
+ display: grid;
266
+ grid-template-columns: 1fr;
267
+ max-width: 850px;
268
+ margin: 2rem auto;
269
+ gap: 2rem;
270
+ }
271
+
272
+ .gridItem {
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 0.75rem;
276
+ align-items: center;
277
+ }
278
+
279
+ .gridItemLabel {
280
+ font-family: var(--font-mono);
281
+ font-size: 1rem;
282
+ font-weight: 700;
283
+ color: var(--purple);
284
+ text-transform: uppercase;
285
+ letter-spacing: 1px;
286
+ }
287
+
288
+ .oodGridImage {
289
+ width: 100%;
290
+ border-radius: 12px;
291
+ border: 1px solid var(--border-glass);
292
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
293
+ transition: transform 0.3s ease;
294
+ }
295
+
296
+ .oodGridImage:hover {
297
+ transform: scale(1.02);
298
+ }
299
+
300
+ @media (max-width: 768px) {
301
+ .oodContainer {
302
+ flex-direction: column;
303
+ gap: 15px;
304
+ }
305
+ .oodImageSmall, .oodImageLarge {
306
+ width: 100% !important;
307
+ }
308
+ }
309
+
310
+ /* Tablets (landscape & portrait) */
311
+ @media (max-width: 1024px) {
312
+ .article {
313
+ width: 80%;
314
+ }
315
+ .container {
316
+ padding: 5rem 1.5rem 3rem;
317
+ }
318
+ }
319
+
320
+ /* Small tablets & large phones */
321
+ @media (max-width: 768px) {
322
+ .article {
323
+ width: 92%;
324
+ padding: 2rem 1.5rem;
325
+ border-radius: 20px;
326
+ }
327
+ .container {
328
+ padding: 4rem 1rem 3rem;
329
+ }
330
+ .header {
331
+ margin-bottom: 2.5rem;
332
+ }
333
+ .section {
334
+ margin-bottom: 2.5rem;
335
+ }
336
+ }
337
+
338
+ /* Small phones */
339
+ @media (max-width: 480px) {
340
+ .article {
341
+ width: 96%;
342
+ padding: 1.5rem 1.25rem;
343
+ border-radius: 16px;
344
+ }
345
+ .container {
346
+ padding: 3.5rem 0.5rem 2rem;
347
+ }
348
+ .header {
349
+ margin-bottom: 2rem;
350
+ padding-bottom: 1.5rem;
351
+ }
352
+ .listItem {
353
+ padding-left: 1.5rem;
354
+ }
355
+ }
frontend/src/components/ConfidenceBar.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import styles from './ConfidenceBar.module.css';
4
+
5
+ export default function ConfidenceBar({ confidence }) {
6
+ const [width, setWidth] = useState(0);
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);
14
+ }, [percentage]);
15
+
16
+ let colorClass = styles.high;
17
+ if (confidence < 0.9) colorClass = styles.medium;
18
+ if (confidence < 0.5) colorClass = styles.low;
19
+
20
+ return (
21
+ <div className={styles.container}>
22
+ <div className={styles.labelRow}>
23
+ <span className={styles.label}>Match Confidence</span>
24
+ <span className={`${styles.value} mono`}>{percentage}%</span>
25
+ </div>
26
+ <div className={styles.track}>
27
+ <div
28
+ className={`${styles.fill} ${colorClass}`}
29
+ style={{ width: `${width}%` }}
30
+ />
31
+ </div>
32
+ </div>
33
+ );
34
+ }
frontend/src/components/ConfidenceBar.module.css ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .container {
2
+ width: 100%;
3
+ margin: 1.5rem 0;
4
+ }
5
+
6
+ .labelRow {
7
+ display: flex;
8
+ justify-content: space-between;
9
+ align-items: flex-end;
10
+ margin-bottom: 8px;
11
+ }
12
+
13
+ .label {
14
+ font-size: 0.9rem;
15
+ color: var(--text-secondary);
16
+ font-weight: 500;
17
+ text-transform: uppercase;
18
+ letter-spacing: 0.5px;
19
+ }
20
+
21
+ .value {
22
+ font-size: 1.1rem;
23
+ font-weight: 700;
24
+ color: var(--text-primary);
25
+ }
26
+
27
+ .track {
28
+ width: 100%;
29
+ height: 8px;
30
+ background-color: rgba(255, 255, 255, 0.1);
31
+ border-radius: 4px;
32
+ overflow: hidden;
33
+ }
34
+
35
+ .fill {
36
+ height: 100%;
37
+ border-radius: 4px;
38
+ transition: width 1s cubic-bezier(0.2, 0.8, 0.2, 1);
39
+ box-shadow: 0 0 10px currentColor;
40
+ }
41
+
42
+ .high {
43
+ background: linear-gradient(90deg, #4ade80, #22c55e);
44
+ color: rgba(34, 197, 94, 0.5);
45
+ }
46
+
47
+ .medium {
48
+ background: linear-gradient(90deg, #facc15, #eab308);
49
+ color: rgba(234, 179, 8, 0.5);
50
+ }
51
+
52
+ .low {
53
+ background: linear-gradient(90deg, #f87171, #ef4444);
54
+ color: rgba(239, 68, 68, 0.5);
55
+ }
frontend/src/components/FontScaler.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ 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(() => {
11
+ document.documentElement.style.setProperty('--font-scale', scale);
12
+ }, [scale]);
13
+
14
+ const increaseFont = () => setScale(prev => Math.min(prev + 0.1, 2.0));
15
+ const decreaseFont = () => setScale(prev => Math.max(prev - 0.1, 0.8));
16
+
17
+ return (
18
+ <div className={styles.controls}>
19
+ <span className={styles.label}>Text Size</span>
20
+ <button onClick={decreaseFont} className={styles.button} aria-label="Decrease font size">A-</button>
21
+ <button onClick={increaseFont} className={styles.button} aria-label="Increase font size">A+</button>
22
+ </div>
23
+ );
24
+ }
frontend/src/components/FontScaler.module.css ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .controls {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.5rem;
5
+ background: rgba(255, 255, 255, 0.05);
6
+ padding: 0.5rem 1rem;
7
+ border-radius: 20px;
8
+ border: 1px solid var(--border-glass);
9
+ }
10
+
11
+ .label {
12
+ font-size: 0.85rem;
13
+ color: var(--text-secondary);
14
+ text-transform: uppercase;
15
+ letter-spacing: 1px;
16
+ margin-right: 0.5rem;
17
+ }
18
+
19
+ .button {
20
+ background: rgba(255, 255, 255, 0.1);
21
+ border: 1px solid rgba(255, 255, 255, 0.2);
22
+ color: var(--text-primary);
23
+ font-weight: bold;
24
+ cursor: pointer;
25
+ padding: 0.25rem 0.75rem;
26
+ border-radius: 8px;
27
+ transition: all 0.2s ease;
28
+ }
29
+
30
+ .button:hover {
31
+ background: var(--accent-primary);
32
+ color: #fff;
33
+ border-color: var(--accent-primary);
34
+ }