Spaces:
Sleeping
Sleeping
Commit ·
f2bfbb5
0
Parent(s):
feat: initialize Pokedex Web project with FastAPI backend and React frontend
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +39 -0
- .gitattributes +3 -0
- .gitignore +52 -0
- Dockerfile +62 -0
- README.md +152 -0
- backend/app.py +208 -0
- backend/config.py +55 -0
- backend/model.py +13 -0
- backend/poke_database.json +0 -0
- backend/pokedex_model.pth +3 -0
- backend/pokemon_service.py +144 -0
- backend/predict.py +289 -0
- backend/rate_limiter.py +46 -0
- backend/requirements.txt +9 -0
- frontend/eslint.config.mjs +16 -0
- frontend/jsconfig.json +7 -0
- frontend/next.config.mjs +9 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +21 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/static/images/1.png +3 -0
- frontend/public/static/images/augmentation_visual.png +3 -0
- frontend/public/static/images/digimon.png +3 -0
- frontend/public/static/images/mosaic_confusion_extreme_cases.png +3 -0
- frontend/public/static/images/pipeline_visual.png +3 -0
- frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_121.pth.png +3 -0
- frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_25.pth.png +3 -0
- frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_255.pth.png +3 -0
- frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_3.pth.png +3 -0
- frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_7.pth.png +3 -0
- frontend/public/static/images/training_curves_multirun_seed_121.png +3 -0
- frontend/public/static/images/training_curves_multirun_seed_25.png +3 -0
- frontend/public/static/images/training_curves_multirun_seed_255.png +3 -0
- frontend/public/static/images/training_curves_multirun_seed_3.png +3 -0
- frontend/public/static/images/training_curves_multirun_seed_7.png +3 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +182 -0
- frontend/src/app/layout.js +14 -0
- frontend/src/app/page.js +135 -0
- frontend/src/app/page.module.css +142 -0
- frontend/src/app/research/page.js +355 -0
- frontend/src/app/research/page.module.css +355 -0
- frontend/src/components/ConfidenceBar.js +34 -0
- frontend/src/components/ConfidenceBar.module.css +55 -0
- frontend/src/components/FontScaler.js +24 -0
- 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
|
frontend/public/static/images/augmentation_visual.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/digimon.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/mosaic_confusion_extreme_cases.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/pipeline_visual.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_121.pth.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_25.pth.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_255.pth.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_3.pth.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/softmax_ood_dark_en_best_model_multirun_seed_7.pth.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/training_curves_multirun_seed_121.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/training_curves_multirun_seed_25.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/training_curves_multirun_seed_255.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/training_curves_multirun_seed_3.png
ADDED
|
Git LFS Details
|
frontend/public/static/images/training_curves_multirun_seed_7.png
ADDED
|
Git LFS Details
|
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 "Who's That Pokémon?" 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's specific proportions and artistic conventions, limiting its ability to generalize across the franchise'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' 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'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'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 "Rhamphomon" (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'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'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'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 |
+
}
|