Spaces:
Sleeping
Sleeping
Commit ·
401aaf2
0
Parent(s):
Initial commit: SatDetect satellite change detection web app
Browse files- .dockerignore +8 -0
- .gitignore +10 -0
- Dockerfile +28 -0
- README.md +77 -0
- app/__init__.py +1 -0
- app/auth.py +88 -0
- app/database.py +28 -0
- app/detection_engine.py +817 -0
- app/main.py +288 -0
- app/models.py +35 -0
- data/overlays/.gitkeep +0 -0
- render.yaml +22 -0
- requirements.txt +13 -0
- run.bat +8 -0
- run.ps1 +8 -0
- static/css/style.css +683 -0
- static/js/app.js +358 -0
- templates/index.html +227 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
data/satellite_app.db
|
| 6 |
+
data/overlays/*
|
| 7 |
+
*.md
|
| 8 |
+
.env
|
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
data/satellite_app.db
|
| 5 |
+
data/overlays/*
|
| 6 |
+
!data/overlays/.gitkeep
|
| 7 |
+
.env
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System dependencies for OpenCV and image processing
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
libgl1 \
|
| 6 |
+
libglib2.0-0 \
|
| 7 |
+
libsm6 \
|
| 8 |
+
libxext6 \
|
| 9 |
+
libxrender1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# Install Python dependencies
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy application code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Create data directories
|
| 22 |
+
RUN mkdir -p data/overlays
|
| 23 |
+
|
| 24 |
+
# Expose port
|
| 25 |
+
EXPOSE 10000
|
| 26 |
+
|
| 27 |
+
# Run with gunicorn + uvicorn workers for production
|
| 28 |
+
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:10000", "--workers", "2", "--timeout", "120"]
|
README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Satellite Change Detection — Standalone Web App
|
| 2 |
+
|
| 3 |
+
Standalone web application for satellite image change detection with **user accounts**, **database storage**, and a **clean, modern UI**.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Login / Register** — JWT-based auth, passwords hashed with bcrypt
|
| 8 |
+
- **Database** — SQLite (or set `DATABASE_URL` for PostgreSQL); stores users and detection runs
|
| 9 |
+
- **Change detection** — Same model as the original app: AI-based, image difference, feature-based, hybrid
|
| 10 |
+
- **Object classification** — Changed regions labeled as Water, Vegetation/Tree, Building, Road, Bare Ground/Soil
|
| 11 |
+
- **History** — List of past runs with overlay images and stats
|
| 12 |
+
- **UI** — Single-page app with a dark, “control room” style and teal accents
|
| 13 |
+
|
| 14 |
+
## Setup
|
| 15 |
+
|
| 16 |
+
1. **Create a virtual environment (recommended)**
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
cd change_detection_webapp
|
| 20 |
+
python -m venv venv
|
| 21 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
2. **Install dependencies**
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
pip install -r requirements.txt
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
3. **Run the app**
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
4. Open **http://localhost:8000** in your browser.
|
| 37 |
+
|
| 38 |
+
## First run
|
| 39 |
+
|
| 40 |
+
- The SQLite DB and `data/` (overlay images) are created automatically on first use.
|
| 41 |
+
- Register a new account from the welcome screen, then sign in.
|
| 42 |
+
- Upload **Before** and **After** images, choose a method, and click **Run detection**.
|
| 43 |
+
- Results appear below; runs are saved in **History**.
|
| 44 |
+
|
| 45 |
+
## Configuration
|
| 46 |
+
|
| 47 |
+
- **Database**: set `DATABASE_URL` (e.g. `postgresql://user:pass@host/db`) to use another DB; otherwise SQLite under `data/satellite_app.db` is used.
|
| 48 |
+
- **JWT**: set `SECRET_KEY` in `app/auth.py` (or via env) in production.
|
| 49 |
+
|
| 50 |
+
## Project layout
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
change_detection_webapp/
|
| 54 |
+
├── app/
|
| 55 |
+
│ ├── main.py # FastAPI app, routes
|
| 56 |
+
│ ├── database.py # SQLAlchemy, session
|
| 57 |
+
│ ├── models.py # User, DetectionRun
|
| 58 |
+
│ ├── auth.py # JWT, password hashing
|
| 59 |
+
│ └── detection_engine.py # Change detection (no Streamlit)
|
| 60 |
+
├── static/
|
| 61 |
+
│ ├── css/style.css # Styles
|
| 62 |
+
│ └── js/app.js # Frontend logic
|
| 63 |
+
├── templates/
|
| 64 |
+
│ └── index.html # Single-page UI
|
| 65 |
+
├── data/ # Created at runtime (DB + overlays)
|
| 66 |
+
├── requirements.txt
|
| 67 |
+
└── README.md
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## API (for integration)
|
| 71 |
+
|
| 72 |
+
- `POST /api/auth/register` — body: `{ "email", "password", "full_name" }`
|
| 73 |
+
- `POST /api/auth/login` — body: `{ "email", "password" }` → returns `access_token`
|
| 74 |
+
- `GET /api/me` — header: `Authorization: Bearer <token>`
|
| 75 |
+
- `POST /api/detect` — form: `before`, `after` (files), `method`, `title`, etc. → returns stats, regions, overlay base64
|
| 76 |
+
- `GET /api/history` — list of current user’s runs
|
| 77 |
+
- `GET /api/overlay/<path>` — serve saved overlay image
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Satellite Change Detection Web App
|
app/auth.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from jose import JWTError, jwt
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
from fastapi import Depends, Request
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from .database import get_db
|
| 12 |
+
from .models import User
|
| 13 |
+
|
| 14 |
+
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-fallback-key-change-in-production")
|
| 15 |
+
ALGORITHM = "HS256"
|
| 16 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 17 |
+
COOKIE_NAME = "satellite_token"
|
| 18 |
+
|
| 19 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 20 |
+
security = HTTPBearer(auto_error=False)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 24 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_password_hash(password: str) -> str:
|
| 28 |
+
return pwd_context.hash(password)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 32 |
+
to_encode = data.copy()
|
| 33 |
+
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
| 34 |
+
to_encode.update({"exp": expire})
|
| 35 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
| 39 |
+
return db.query(User).filter(User.email == email).first()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
| 43 |
+
return db.query(User).filter(User.id == user_id).first()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_user_from_token(token: str, db: Session) -> Optional[User]:
|
| 47 |
+
"""Resolve user from JWT token (used as fallback when header/cookie not sent)."""
|
| 48 |
+
if not token:
|
| 49 |
+
print("[AUTH] get_user_from_token: token is empty/None")
|
| 50 |
+
return None
|
| 51 |
+
try:
|
| 52 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 53 |
+
user_id_str = payload.get("sub")
|
| 54 |
+
print(f"[AUTH] decoded token OK, sub={user_id_str}")
|
| 55 |
+
if user_id_str is None:
|
| 56 |
+
return None
|
| 57 |
+
try:
|
| 58 |
+
user_id = int(user_id_str)
|
| 59 |
+
except (ValueError, TypeError):
|
| 60 |
+
return None
|
| 61 |
+
except JWTError as e:
|
| 62 |
+
print(f"[AUTH] JWT decode FAILED: {e}")
|
| 63 |
+
return None
|
| 64 |
+
user = get_user_by_id(db, user_id)
|
| 65 |
+
print(f"[AUTH] DB lookup: user={'found' if user else 'NOT FOUND'}")
|
| 66 |
+
return user
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_current_user(
|
| 70 |
+
request: Request,
|
| 71 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 72 |
+
db: Session = Depends(get_db),
|
| 73 |
+
) -> Optional[User]:
|
| 74 |
+
print(f"[AUTH] get_current_user called")
|
| 75 |
+
print(f"[AUTH] credentials present: {credentials is not None}")
|
| 76 |
+
print(f"[AUTH] cookie present: {request.cookies.get(COOKIE_NAME) is not None}")
|
| 77 |
+
print(f"[AUTH] Authorization header: {request.headers.get('authorization', 'MISSING')[:50]}")
|
| 78 |
+
# 1) Try Bearer header
|
| 79 |
+
if credentials:
|
| 80 |
+
user = get_user_from_token(credentials.credentials, db)
|
| 81 |
+
if user:
|
| 82 |
+
return user
|
| 83 |
+
# 2) Try cookie (sent automatically by browser on same-origin requests)
|
| 84 |
+
token = request.cookies.get(COOKIE_NAME)
|
| 85 |
+
if token:
|
| 86 |
+
return get_user_from_token(token, db)
|
| 87 |
+
print("[AUTH] No valid auth found, returning None")
|
| 88 |
+
return None
|
app/database.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import create_engine
|
| 5 |
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 8 |
+
DB_PATH = BASE_DIR.parent / "data" / "satellite_app.db"
|
| 9 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 10 |
+
|
| 11 |
+
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}")
|
| 12 |
+
|
| 13 |
+
# Render gives postgres:// but SQLAlchemy 2.x requires postgresql://
|
| 14 |
+
if DATABASE_URL.startswith("postgres://"):
|
| 15 |
+
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
|
| 16 |
+
|
| 17 |
+
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
| 18 |
+
engine = create_engine(DATABASE_URL, connect_args=connect_args)
|
| 19 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 20 |
+
Base = declarative_base()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_db():
|
| 24 |
+
db = SessionLocal()
|
| 25 |
+
try:
|
| 26 |
+
yield db
|
| 27 |
+
finally:
|
| 28 |
+
db.close()
|
app/detection_engine.py
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Satellite Change Detection Engine v2
|
| 3 |
+
High-accuracy detection with multi-channel analysis, SSIM, texture features,
|
| 4 |
+
adaptive thresholding, and improved object classification.
|
| 5 |
+
"""
|
| 6 |
+
import io
|
| 7 |
+
import numpy as np
|
| 8 |
+
import cv2
|
| 9 |
+
from PIL import Image
|
| 10 |
+
from sklearn.cluster import KMeans
|
| 11 |
+
from sklearn.preprocessing import StandardScaler
|
| 12 |
+
from collections import Counter
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# 1. Pre-processing
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def preprocess_image(image):
|
| 20 |
+
"""Preprocess image: convert to RGB, limit size."""
|
| 21 |
+
img_array = np.array(image)
|
| 22 |
+
if img_array.ndim == 2:
|
| 23 |
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
| 24 |
+
if img_array.shape[2] == 4:
|
| 25 |
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
|
| 26 |
+
max_size = 2000
|
| 27 |
+
height, width = img_array.shape[:2]
|
| 28 |
+
if max(height, width) > max_size:
|
| 29 |
+
scale = max_size / max(height, width)
|
| 30 |
+
img_array = cv2.resize(img_array, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_AREA)
|
| 31 |
+
return img_array
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# 2. Improved image registration (alignment)
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
def register_images(img1, img2, max_features=2000):
|
| 39 |
+
"""Align img2 to img1 using ORB + ratio-test + RANSAC homography."""
|
| 40 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 41 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 42 |
+
|
| 43 |
+
orb = cv2.ORB_create(nfeatures=max_features, scoreType=cv2.ORB_HARRIS_SCORE)
|
| 44 |
+
kp1, des1 = orb.detectAndCompute(gray1, None)
|
| 45 |
+
kp2, des2 = orb.detectAndCompute(gray2, None)
|
| 46 |
+
|
| 47 |
+
if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
|
| 48 |
+
return img1, img2, False
|
| 49 |
+
|
| 50 |
+
# Use kNN matching with Lowe's ratio test for better matches
|
| 51 |
+
bf = cv2.BFMatcher(cv2.NORM_HAMMING)
|
| 52 |
+
raw_matches = bf.knnMatch(des1, des2, k=2)
|
| 53 |
+
|
| 54 |
+
good_matches = []
|
| 55 |
+
for pair in raw_matches:
|
| 56 |
+
if len(pair) == 2:
|
| 57 |
+
m, n = pair
|
| 58 |
+
if m.distance < 0.75 * n.distance:
|
| 59 |
+
good_matches.append(m)
|
| 60 |
+
|
| 61 |
+
if len(good_matches) < 10:
|
| 62 |
+
return img1, img2, False
|
| 63 |
+
|
| 64 |
+
src_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
|
| 65 |
+
dst_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
|
| 66 |
+
|
| 67 |
+
homography, mask = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 3.0)
|
| 68 |
+
if homography is None:
|
| 69 |
+
return img1, img2, False
|
| 70 |
+
|
| 71 |
+
# Only accept if enough inliers
|
| 72 |
+
inlier_ratio = np.sum(mask) / len(mask) if mask is not None else 0
|
| 73 |
+
if inlier_ratio < 0.3:
|
| 74 |
+
return img1, img2, False
|
| 75 |
+
|
| 76 |
+
h, w = img1.shape[:2]
|
| 77 |
+
img2_aligned = cv2.warpPerspective(img2, homography, (w, h), borderMode=cv2.BORDER_REFLECT)
|
| 78 |
+
return img1, img2_aligned, True
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# 3. Improved radiometric normalization
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
def normalize_radiometry(img1, img2):
|
| 86 |
+
"""Histogram-matching normalization in LAB space for all channels."""
|
| 87 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 88 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 89 |
+
|
| 90 |
+
result = lab2.copy()
|
| 91 |
+
for ch in range(3):
|
| 92 |
+
mean1, std1 = np.mean(lab1[:, :, ch]), np.std(lab1[:, :, ch])
|
| 93 |
+
mean2, std2 = np.mean(lab2[:, :, ch]), np.std(lab2[:, :, ch])
|
| 94 |
+
if std2 > 1e-6:
|
| 95 |
+
result[:, :, ch] = (lab2[:, :, ch] - mean2) * (std1 / std2) + mean1
|
| 96 |
+
|
| 97 |
+
# Also apply CLAHE on L channel for contrast equalization
|
| 98 |
+
result_uint8 = np.clip(result, 0, 255).astype(np.uint8)
|
| 99 |
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 100 |
+
result_uint8[:, :, 0] = clahe.apply(result_uint8[:, :, 0])
|
| 101 |
+
|
| 102 |
+
img2_normalized = cv2.cvtColor(result_uint8, cv2.COLOR_LAB2RGB)
|
| 103 |
+
return img1, img2_normalized
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# 4. SSIM-based structural change map
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def compute_ssim_change_map(img1, img2, win_size=7):
|
| 111 |
+
"""Compute per-pixel structural dissimilarity (1 - SSIM)."""
|
| 112 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 113 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 114 |
+
|
| 115 |
+
C1 = (0.01 * 255) ** 2
|
| 116 |
+
C2 = (0.03 * 255) ** 2
|
| 117 |
+
|
| 118 |
+
mu1 = cv2.GaussianBlur(gray1, (win_size, win_size), 1.5)
|
| 119 |
+
mu2 = cv2.GaussianBlur(gray2, (win_size, win_size), 1.5)
|
| 120 |
+
|
| 121 |
+
mu1_sq = mu1 * mu1
|
| 122 |
+
mu2_sq = mu2 * mu2
|
| 123 |
+
mu1_mu2 = mu1 * mu2
|
| 124 |
+
|
| 125 |
+
sigma1_sq = cv2.GaussianBlur(gray1 * gray1, (win_size, win_size), 1.5) - mu1_sq
|
| 126 |
+
sigma2_sq = cv2.GaussianBlur(gray2 * gray2, (win_size, win_size), 1.5) - mu2_sq
|
| 127 |
+
sigma12 = cv2.GaussianBlur(gray1 * gray2, (win_size, win_size), 1.5) - mu1_mu2
|
| 128 |
+
|
| 129 |
+
ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / \
|
| 130 |
+
((mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2))
|
| 131 |
+
|
| 132 |
+
# Structural dissimilarity: 0 = identical, 1 = completely different
|
| 133 |
+
dssim = np.clip((1.0 - ssim_map) / 2.0, 0, 1)
|
| 134 |
+
return dssim
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# 5. Texture feature extraction (LBP)
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def compute_lbp(gray, radius=1, n_points=8):
|
| 142 |
+
"""Compute simplified Local Binary Pattern texture descriptor."""
|
| 143 |
+
h, w = gray.shape
|
| 144 |
+
lbp = np.zeros_like(gray, dtype=np.float32)
|
| 145 |
+
for i in range(n_points):
|
| 146 |
+
angle = 2 * np.pi * i / n_points
|
| 147 |
+
dx = int(round(radius * np.cos(angle)))
|
| 148 |
+
dy = int(round(-radius * np.sin(angle)))
|
| 149 |
+
shifted = np.roll(np.roll(gray, dy, axis=0), dx, axis=1)
|
| 150 |
+
lbp += (shifted >= gray).astype(np.float32)
|
| 151 |
+
return lbp / n_points
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def compute_texture_change(img1, img2):
|
| 155 |
+
"""Compute texture difference using LBP."""
|
| 156 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 157 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 158 |
+
lbp1 = compute_lbp(gray1)
|
| 159 |
+
lbp2 = compute_lbp(gray2)
|
| 160 |
+
texture_diff = np.abs(lbp1 - lbp2)
|
| 161 |
+
return texture_diff
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# 6. Edge-aware change detection
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
def compute_edge_change(img1, img2):
|
| 169 |
+
"""Compute edge-based change map using Canny edges."""
|
| 170 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 171 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 172 |
+
|
| 173 |
+
# Adaptive Canny thresholds based on median intensity
|
| 174 |
+
med1 = np.median(gray1)
|
| 175 |
+
edges1 = cv2.Canny(gray1, int(max(0, 0.67 * med1)), int(min(255, 1.33 * med1)))
|
| 176 |
+
med2 = np.median(gray2)
|
| 177 |
+
edges2 = cv2.Canny(gray2, int(max(0, 0.67 * med2)), int(min(255, 1.33 * med2)))
|
| 178 |
+
|
| 179 |
+
# Dilate edges slightly so nearby edges match
|
| 180 |
+
kernel = np.ones((3, 3), np.uint8)
|
| 181 |
+
edges1_d = cv2.dilate(edges1, kernel, iterations=1)
|
| 182 |
+
edges2_d = cv2.dilate(edges2, kernel, iterations=1)
|
| 183 |
+
|
| 184 |
+
# New edges = present in one image but not the other
|
| 185 |
+
edge_change = cv2.absdiff(edges1_d, edges2_d).astype(np.float32) / 255.0
|
| 186 |
+
return edge_change
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
# 7. Improved detection methods
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
|
| 193 |
+
def image_difference_method(img1, img2, threshold=0.25, blur_size=5):
|
| 194 |
+
"""Improved image difference with multi-channel analysis and adaptive threshold."""
|
| 195 |
+
if img1.shape != img2.shape:
|
| 196 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 197 |
+
|
| 198 |
+
# Multi-channel difference in LAB (perceptually uniform)
|
| 199 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 200 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 201 |
+
|
| 202 |
+
lab1_blur = cv2.GaussianBlur(lab1, (blur_size, blur_size), 0)
|
| 203 |
+
lab2_blur = cv2.GaussianBlur(lab2, (blur_size, blur_size), 0)
|
| 204 |
+
|
| 205 |
+
# Weighted Delta-E inspired difference
|
| 206 |
+
diff = lab1_blur - lab2_blur
|
| 207 |
+
delta_e = np.sqrt(
|
| 208 |
+
(diff[:, :, 0] / 100.0) ** 2 +
|
| 209 |
+
(diff[:, :, 1] / 128.0) ** 2 +
|
| 210 |
+
(diff[:, :, 2] / 128.0) ** 2
|
| 211 |
+
)
|
| 212 |
+
delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
|
| 213 |
+
|
| 214 |
+
# Adaptive threshold using Otsu on the change map
|
| 215 |
+
delta_uint8 = (delta_e * 255).astype(np.uint8)
|
| 216 |
+
_, change_mask = cv2.threshold(delta_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 217 |
+
|
| 218 |
+
change_mask = _clean_mask(change_mask)
|
| 219 |
+
return change_mask
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
|
| 223 |
+
"""Feature-based change detection using multi-space clustering."""
|
| 224 |
+
if img1.shape != img2.shape:
|
| 225 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 226 |
+
|
| 227 |
+
# Combine LAB and HSV differences for richer features
|
| 228 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 229 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 230 |
+
hsv1 = cv2.cvtColor(img1, cv2.COLOR_RGB2HSV).astype(np.float32)
|
| 231 |
+
hsv2 = cv2.cvtColor(img2, cv2.COLOR_RGB2HSV).astype(np.float32)
|
| 232 |
+
|
| 233 |
+
diff_lab = np.abs(lab1 - lab2)
|
| 234 |
+
diff_hsv = np.abs(hsv1 - hsv2)
|
| 235 |
+
|
| 236 |
+
h, w, _ = diff_lab.shape
|
| 237 |
+
features = np.concatenate([diff_lab, diff_hsv[:, :, 1:]], axis=2) # 5 channels
|
| 238 |
+
features_flat = features.reshape(-1, features.shape[2])
|
| 239 |
+
|
| 240 |
+
scaler = StandardScaler()
|
| 241 |
+
features_scaled = scaler.fit_transform(features_flat)
|
| 242 |
+
|
| 243 |
+
kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init=10)
|
| 244 |
+
labels = kmeans.fit_predict(features_scaled)
|
| 245 |
+
|
| 246 |
+
# Find the cluster with highest mean difference (= change)
|
| 247 |
+
cluster_means = [np.mean(np.linalg.norm(features_flat[labels == i], axis=1)) for i in range(num_clusters)]
|
| 248 |
+
change_cluster_idx = np.argmax(cluster_means)
|
| 249 |
+
|
| 250 |
+
change_mask = (labels == change_cluster_idx).astype(np.uint8) * 255
|
| 251 |
+
change_mask = change_mask.reshape(h, w)
|
| 252 |
+
|
| 253 |
+
change_mask = _clean_mask(change_mask, sensitivity)
|
| 254 |
+
return change_mask
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def ai_deep_learning_method(img1, img2):
|
| 258 |
+
"""
|
| 259 |
+
Advanced multi-signal fusion:
|
| 260 |
+
- Multi-scale color difference (LAB)
|
| 261 |
+
- Structural dissimilarity (SSIM)
|
| 262 |
+
- Texture change (LBP)
|
| 263 |
+
- Edge change (Canny)
|
| 264 |
+
All fused with learned weights and adaptive thresholding.
|
| 265 |
+
"""
|
| 266 |
+
if img1.shape != img2.shape:
|
| 267 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 268 |
+
|
| 269 |
+
# ---- Channel 1: Multi-scale LAB color difference ----
|
| 270 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 271 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 272 |
+
|
| 273 |
+
scales = [1, 2, 4]
|
| 274 |
+
color_maps = []
|
| 275 |
+
for scale in scales:
|
| 276 |
+
if scale > 1:
|
| 277 |
+
s1 = cv2.resize(lab1, (lab1.shape[1] // scale, lab1.shape[0] // scale))
|
| 278 |
+
s2 = cv2.resize(lab2, (lab2.shape[1] // scale, lab2.shape[0] // scale))
|
| 279 |
+
else:
|
| 280 |
+
s1, s2 = lab1, lab2
|
| 281 |
+
diff = s1 - s2
|
| 282 |
+
# Delta-E (CIE76) normalized
|
| 283 |
+
delta_e = np.sqrt((diff[:, :, 0] / 100.0) ** 2 +
|
| 284 |
+
(diff[:, :, 1] / 128.0) ** 2 +
|
| 285 |
+
(diff[:, :, 2] / 128.0) ** 2)
|
| 286 |
+
if scale > 1:
|
| 287 |
+
delta_e = cv2.resize(delta_e, (lab1.shape[1], lab1.shape[0]))
|
| 288 |
+
color_maps.append(delta_e)
|
| 289 |
+
|
| 290 |
+
color_change = np.mean(color_maps, axis=0)
|
| 291 |
+
color_change = color_change / (color_change.max() + 1e-8)
|
| 292 |
+
|
| 293 |
+
# ---- Channel 2: SSIM structural dissimilarity ----
|
| 294 |
+
ssim_change = compute_ssim_change_map(img1, img2)
|
| 295 |
+
ssim_change = ssim_change / (ssim_change.max() + 1e-8)
|
| 296 |
+
|
| 297 |
+
# ---- Channel 3: Texture change (LBP) ----
|
| 298 |
+
texture_change = compute_texture_change(img1, img2)
|
| 299 |
+
texture_change = texture_change / (texture_change.max() + 1e-8)
|
| 300 |
+
|
| 301 |
+
# ---- Channel 4: Edge change ----
|
| 302 |
+
edge_change = compute_edge_change(img1, img2)
|
| 303 |
+
|
| 304 |
+
# ---- Adaptive fusion ----
|
| 305 |
+
# Weight channels by their discriminative power (entropy-based)
|
| 306 |
+
channels = [color_change, ssim_change, texture_change, edge_change]
|
| 307 |
+
weights = []
|
| 308 |
+
for ch in channels:
|
| 309 |
+
ch_uint8 = (ch * 255).astype(np.uint8)
|
| 310 |
+
hist = cv2.calcHist([ch_uint8], [0], None, [256], [0, 256]).flatten()
|
| 311 |
+
hist = hist / (hist.sum() + 1e-8)
|
| 312 |
+
entropy = -np.sum(hist[hist > 0] * np.log2(hist[hist > 0] + 1e-10))
|
| 313 |
+
weights.append(entropy)
|
| 314 |
+
|
| 315 |
+
# Normalize weights
|
| 316 |
+
total_w = sum(weights) + 1e-8
|
| 317 |
+
weights = [w / total_w for w in weights]
|
| 318 |
+
|
| 319 |
+
# Fuse
|
| 320 |
+
fused = np.zeros_like(color_change, dtype=np.float64)
|
| 321 |
+
for ch, w in zip(channels, weights):
|
| 322 |
+
fused += w * ch.astype(np.float64)
|
| 323 |
+
|
| 324 |
+
fused = fused / (fused.max() + 1e-8)
|
| 325 |
+
fused_uint8 = (fused * 255).astype(np.uint8)
|
| 326 |
+
|
| 327 |
+
# Adaptive threshold: Otsu + refinement
|
| 328 |
+
_, change_mask = cv2.threshold(fused_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 329 |
+
|
| 330 |
+
# Post-process
|
| 331 |
+
change_mask = _clean_mask(change_mask)
|
| 332 |
+
|
| 333 |
+
# Edge-preserving smoothing on the mask
|
| 334 |
+
change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
|
| 335 |
+
_, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
|
| 336 |
+
|
| 337 |
+
return change_mask
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def hybrid_method(img1, img2):
|
| 341 |
+
"""Hybrid: weighted fusion of all methods with confidence-based merging."""
|
| 342 |
+
if img1.shape != img2.shape:
|
| 343 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 344 |
+
|
| 345 |
+
diff_mask = image_difference_method(img1, img2)
|
| 346 |
+
feature_mask = feature_based_method(img1, img2)
|
| 347 |
+
ai_mask = ai_deep_learning_method(img1, img2)
|
| 348 |
+
|
| 349 |
+
# Weighted combination: AI method gets most weight
|
| 350 |
+
combined = (
|
| 351 |
+
0.2 * diff_mask.astype(np.float32) +
|
| 352 |
+
0.3 * feature_mask.astype(np.float32) +
|
| 353 |
+
0.5 * ai_mask.astype(np.float32)
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
_, final_mask = cv2.threshold(combined.astype(np.uint8), 127, 255, cv2.THRESH_BINARY)
|
| 357 |
+
final_mask = _clean_mask(final_mask)
|
| 358 |
+
return final_mask
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ---------------------------------------------------------------------------
|
| 362 |
+
# 8. Robust post-processing
|
| 363 |
+
# ---------------------------------------------------------------------------
|
| 364 |
+
|
| 365 |
+
def _clean_mask(mask, sensitivity=0.5):
|
| 366 |
+
"""Adaptive morphological cleaning: close gaps, remove noise, fill holes."""
|
| 367 |
+
# Close small gaps
|
| 368 |
+
close_size = max(3, int(7 * (1 - sensitivity)))
|
| 369 |
+
if close_size % 2 == 0:
|
| 370 |
+
close_size += 1
|
| 371 |
+
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
|
| 372 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close)
|
| 373 |
+
|
| 374 |
+
# Remove small noise
|
| 375 |
+
open_size = 3
|
| 376 |
+
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
|
| 377 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open)
|
| 378 |
+
|
| 379 |
+
# Fill small holes inside detected regions
|
| 380 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 381 |
+
filled = np.zeros_like(mask)
|
| 382 |
+
cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
|
| 383 |
+
|
| 384 |
+
return filled
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---------------------------------------------------------------------------
|
| 388 |
+
# 9. Improved visualization
|
| 389 |
+
# ---------------------------------------------------------------------------
|
| 390 |
+
|
| 391 |
+
def visualize_changes(img1, img2, change_mask, regions=None):
|
| 392 |
+
"""Overlay change mask on 'after' image in RED."""
|
| 393 |
+
if img1.shape != img2.shape:
|
| 394 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 395 |
+
if change_mask.shape[:2] != img2.shape[:2]:
|
| 396 |
+
change_mask = cv2.resize(change_mask, (img2.shape[1], img2.shape[0]))
|
| 397 |
+
|
| 398 |
+
overlay = img2.copy().astype(np.float32)
|
| 399 |
+
mask_bool = change_mask > 127
|
| 400 |
+
mask_float = mask_bool.astype(np.float32)
|
| 401 |
+
|
| 402 |
+
# Red overlay for all detected changes
|
| 403 |
+
red_layer = np.zeros_like(img2, dtype=np.float32)
|
| 404 |
+
red_layer[:, :, 0] = 255 # pure red
|
| 405 |
+
alpha = 0.50
|
| 406 |
+
for c in range(3):
|
| 407 |
+
overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
|
| 408 |
+
|
| 409 |
+
# Draw thin white outlines around each region for clarity
|
| 410 |
+
if regions:
|
| 411 |
+
contour_mask = np.zeros(change_mask.shape[:2], dtype=np.uint8)
|
| 412 |
+
for r in regions:
|
| 413 |
+
x, y, w, h = r["bbox"]
|
| 414 |
+
cv2.rectangle(contour_mask, (x, y), (x + w, y + h), 255, 1)
|
| 415 |
+
outline = contour_mask > 0
|
| 416 |
+
overlay[outline] = [255, 255, 255]
|
| 417 |
+
|
| 418 |
+
return np.clip(overlay, 0, 255).astype(np.uint8)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ---------------------------------------------------------------------------
|
| 422 |
+
# 10. Improved object classification
|
| 423 |
+
# ---------------------------------------------------------------------------
|
| 424 |
+
|
| 425 |
+
def extract_advanced_features(region):
|
| 426 |
+
"""Extract rich features for classification: color, texture, edge, shape."""
|
| 427 |
+
if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3:
|
| 428 |
+
return None
|
| 429 |
+
|
| 430 |
+
hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
|
| 431 |
+
lab = cv2.cvtColor(region, cv2.COLOR_RGB2LAB)
|
| 432 |
+
gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 433 |
+
|
| 434 |
+
# Color stats
|
| 435 |
+
mean_rgb = np.mean(region, axis=(0, 1))
|
| 436 |
+
std_rgb = np.std(region, axis=(0, 1))
|
| 437 |
+
mean_hsv = np.mean(hsv, axis=(0, 1))
|
| 438 |
+
mean_lab = np.mean(lab, axis=(0, 1))
|
| 439 |
+
|
| 440 |
+
total_rgb = np.sum(mean_rgb) + 1e-6
|
| 441 |
+
green_ratio = mean_rgb[1] / total_rgb
|
| 442 |
+
blue_ratio = mean_rgb[2] / total_rgb
|
| 443 |
+
red_ratio = mean_rgb[0] / total_rgb
|
| 444 |
+
|
| 445 |
+
# Vegetation indices
|
| 446 |
+
ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
|
| 447 |
+
|
| 448 |
+
# Texture
|
| 449 |
+
texture_std = float(np.std(gray))
|
| 450 |
+
lbp = compute_lbp(gray.astype(np.float32))
|
| 451 |
+
lbp_variance = float(np.var(lbp))
|
| 452 |
+
|
| 453 |
+
# Edges
|
| 454 |
+
grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
|
| 455 |
+
grad_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
|
| 456 |
+
edge_mag = np.sqrt(grad_x ** 2 + grad_y ** 2)
|
| 457 |
+
edge_density = float(np.mean(edge_mag))
|
| 458 |
+
|
| 459 |
+
# Edge orientation histogram (structural regularity)
|
| 460 |
+
angles = np.arctan2(grad_y, grad_x + 1e-8)
|
| 461 |
+
angle_hist, _ = np.histogram(angles, bins=8, range=(-np.pi, np.pi))
|
| 462 |
+
angle_hist = angle_hist / (angle_hist.sum() + 1e-8)
|
| 463 |
+
orientation_entropy = -np.sum(angle_hist[angle_hist > 0] * np.log2(angle_hist[angle_hist > 0] + 1e-10))
|
| 464 |
+
|
| 465 |
+
# GLCM-like contrast (simplified: variance of neighbors)
|
| 466 |
+
shifted_r = np.roll(gray, 1, axis=1)
|
| 467 |
+
shifted_d = np.roll(gray, 1, axis=0)
|
| 468 |
+
glcm_contrast = float(np.mean((gray - shifted_r) ** 2 + (gray - shifted_d) ** 2))
|
| 469 |
+
|
| 470 |
+
return {
|
| 471 |
+
"mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
|
| 472 |
+
"ndvi": ndvi, "texture_std": texture_std, "lbp_variance": lbp_variance,
|
| 473 |
+
"edge_density": edge_density, "orientation_entropy": orientation_entropy,
|
| 474 |
+
"glcm_contrast": glcm_contrast,
|
| 475 |
+
"color_homogeneity": float(np.mean(std_rgb)),
|
| 476 |
+
"brightness": float(mean_lab[0]),
|
| 477 |
+
"green_ratio": green_ratio, "blue_ratio": blue_ratio, "red_ratio": red_ratio,
|
| 478 |
+
"saturation": float(mean_hsv[1]), "hue": float(mean_hsv[0]),
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def _is_transient_object(area, w, h, features):
|
| 483 |
+
"""
|
| 484 |
+
Filter out transient objects (people, cars, animals, shadows, etc.)
|
| 485 |
+
that are NOT permanent ground/structural changes.
|
| 486 |
+
Returns True if the region is likely transient and should be excluded.
|
| 487 |
+
"""
|
| 488 |
+
aspect_ratio = max(w, h) / max(min(w, h), 1)
|
| 489 |
+
|
| 490 |
+
# Very small regions are likely noise, people, or small vehicles
|
| 491 |
+
if area < 300:
|
| 492 |
+
return True
|
| 493 |
+
|
| 494 |
+
# Tall narrow regions (aspect > 4) are likely people or poles
|
| 495 |
+
if aspect_ratio > 5.0 and area < 2000:
|
| 496 |
+
return True
|
| 497 |
+
|
| 498 |
+
# Very high edge density + small area = likely a person or vehicle
|
| 499 |
+
if features["edge_density"] > 80 and area < 1500:
|
| 500 |
+
return True
|
| 501 |
+
|
| 502 |
+
# Extremely high texture variance in small area = likely transient clutter
|
| 503 |
+
if features["texture_std"] > 60 and area < 1000:
|
| 504 |
+
return True
|
| 505 |
+
|
| 506 |
+
return False
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# Ground-level change categories only
|
| 510 |
+
GROUND_CHANGE_TYPES = [
|
| 511 |
+
"New Construction/Building",
|
| 512 |
+
"Demolition/Clearing",
|
| 513 |
+
"Vegetation Change",
|
| 514 |
+
"Water Body Change",
|
| 515 |
+
"Road/Pavement Change",
|
| 516 |
+
"Bare Land/Soil Change",
|
| 517 |
+
]
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def classify_object_type(image_region, bbox):
|
| 521 |
+
"""
|
| 522 |
+
Classify GROUND-LEVEL structural changes only.
|
| 523 |
+
Categories: construction, demolition, vegetation, water, road, bare land.
|
| 524 |
+
Transient objects (people, cars, animals) are filtered out.
|
| 525 |
+
"""
|
| 526 |
+
x, y, w, h = bbox
|
| 527 |
+
pad = 5
|
| 528 |
+
y1 = max(0, y - pad)
|
| 529 |
+
y2 = min(image_region.shape[0], y + h + pad)
|
| 530 |
+
x1 = max(0, x - pad)
|
| 531 |
+
x2 = min(image_region.shape[1], x + w + pad)
|
| 532 |
+
region = image_region[y1:y2, x1:x2]
|
| 533 |
+
|
| 534 |
+
if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3:
|
| 535 |
+
return "Unclassified", 0.0
|
| 536 |
+
|
| 537 |
+
features = extract_advanced_features(region)
|
| 538 |
+
if features is None:
|
| 539 |
+
return "Unclassified", 0.0
|
| 540 |
+
|
| 541 |
+
area = w * h
|
| 542 |
+
|
| 543 |
+
# Filter out transient objects (people, cars, animals)
|
| 544 |
+
if _is_transient_object(area, w, h, features):
|
| 545 |
+
return None, 0.0 # signal to exclude this region
|
| 546 |
+
|
| 547 |
+
aspect_ratio = max(w, h) / max(min(w, h), 1)
|
| 548 |
+
compactness = (4 * np.pi * area) / ((2 * (w + h)) ** 2 + 1e-6)
|
| 549 |
+
|
| 550 |
+
scores = {}
|
| 551 |
+
|
| 552 |
+
# ---- Water Body Change ----
|
| 553 |
+
water = 0.0
|
| 554 |
+
if features["blue_ratio"] > 0.36:
|
| 555 |
+
water += 0.22
|
| 556 |
+
if features["texture_std"] < 28:
|
| 557 |
+
water += 0.18
|
| 558 |
+
if features["edge_density"] < 35:
|
| 559 |
+
water += 0.14
|
| 560 |
+
if 90 <= features["hue"] <= 135:
|
| 561 |
+
water += 0.18
|
| 562 |
+
if features["lbp_variance"] < 0.05:
|
| 563 |
+
water += 0.14
|
| 564 |
+
if features["glcm_contrast"] < 500:
|
| 565 |
+
water += 0.10
|
| 566 |
+
if area > 800:
|
| 567 |
+
water += 0.04
|
| 568 |
+
scores["Water Body Change"] = water
|
| 569 |
+
|
| 570 |
+
# ---- Vegetation Change (deforestation, new growth, crop change) ----
|
| 571 |
+
veg = 0.0
|
| 572 |
+
if features["ndvi"] > 0.05:
|
| 573 |
+
veg += 0.22
|
| 574 |
+
if features["ndvi"] > 0.15:
|
| 575 |
+
veg += 0.10
|
| 576 |
+
if features["green_ratio"] > 0.36:
|
| 577 |
+
veg += 0.18
|
| 578 |
+
if 35 <= features["hue"] <= 85:
|
| 579 |
+
veg += 0.15
|
| 580 |
+
if features["texture_std"] > 18:
|
| 581 |
+
veg += 0.08
|
| 582 |
+
if features["lbp_variance"] > 0.03:
|
| 583 |
+
veg += 0.08
|
| 584 |
+
if features["saturation"] > 40:
|
| 585 |
+
veg += 0.10
|
| 586 |
+
if features["orientation_entropy"] > 2.5:
|
| 587 |
+
veg += 0.05
|
| 588 |
+
if area > 500:
|
| 589 |
+
veg += 0.04
|
| 590 |
+
scores["Vegetation Change"] = veg
|
| 591 |
+
|
| 592 |
+
# ---- New Construction/Building ----
|
| 593 |
+
bld = 0.0
|
| 594 |
+
if features["orientation_entropy"] < 2.5:
|
| 595 |
+
bld += 0.18
|
| 596 |
+
if features["color_homogeneity"] < 28:
|
| 597 |
+
bld += 0.15
|
| 598 |
+
if 1.0 <= aspect_ratio <= 4.0:
|
| 599 |
+
bld += 0.12
|
| 600 |
+
if 0.3 <= compactness <= 0.9:
|
| 601 |
+
bld += 0.10
|
| 602 |
+
if features["edge_density"] > 30:
|
| 603 |
+
bld += 0.12
|
| 604 |
+
if features["glcm_contrast"] > 400:
|
| 605 |
+
bld += 0.10
|
| 606 |
+
if features["saturation"] < 90:
|
| 607 |
+
bld += 0.10
|
| 608 |
+
if 40 <= features["brightness"] <= 90:
|
| 609 |
+
bld += 0.08
|
| 610 |
+
if area > 1000:
|
| 611 |
+
bld += 0.05
|
| 612 |
+
scores["New Construction/Building"] = bld
|
| 613 |
+
|
| 614 |
+
# ---- Demolition/Clearing ----
|
| 615 |
+
demo = 0.0
|
| 616 |
+
if features["texture_std"] > 30:
|
| 617 |
+
demo += 0.18
|
| 618 |
+
if features["orientation_entropy"] > 2.8:
|
| 619 |
+
demo += 0.15
|
| 620 |
+
if features["color_homogeneity"] > 25:
|
| 621 |
+
demo += 0.15
|
| 622 |
+
if features["brightness"] > 60:
|
| 623 |
+
demo += 0.10
|
| 624 |
+
if features["ndvi"] < 0.05:
|
| 625 |
+
demo += 0.12
|
| 626 |
+
if features["saturation"] < 70:
|
| 627 |
+
demo += 0.10
|
| 628 |
+
if area > 800:
|
| 629 |
+
demo += 0.05
|
| 630 |
+
scores["Demolition/Clearing"] = demo
|
| 631 |
+
|
| 632 |
+
# ---- Road/Pavement Change ----
|
| 633 |
+
road = 0.0
|
| 634 |
+
if aspect_ratio > 2.5:
|
| 635 |
+
road += 0.22
|
| 636 |
+
if features["color_homogeneity"] < 22:
|
| 637 |
+
road += 0.18
|
| 638 |
+
if features["texture_std"] < 32:
|
| 639 |
+
road += 0.15
|
| 640 |
+
if features["saturation"] < 65:
|
| 641 |
+
road += 0.12
|
| 642 |
+
if features["orientation_entropy"] < 2.0:
|
| 643 |
+
road += 0.15
|
| 644 |
+
if 35 <= features["brightness"] <= 75:
|
| 645 |
+
road += 0.10
|
| 646 |
+
if compactness < 0.3:
|
| 647 |
+
road += 0.05
|
| 648 |
+
if area > 600:
|
| 649 |
+
road += 0.03
|
| 650 |
+
scores["Road/Pavement Change"] = road
|
| 651 |
+
|
| 652 |
+
# ---- Bare Land/Soil Change ----
|
| 653 |
+
soil = 0.0
|
| 654 |
+
if features["red_ratio"] > 0.34 and features["green_ratio"] < 0.36:
|
| 655 |
+
soil += 0.20
|
| 656 |
+
if 8 <= features["hue"] <= 38:
|
| 657 |
+
soil += 0.18
|
| 658 |
+
if features["ndvi"] < 0.05:
|
| 659 |
+
soil += 0.18
|
| 660 |
+
if features["texture_std"] < 35:
|
| 661 |
+
soil += 0.12
|
| 662 |
+
if features["lbp_variance"] < 0.04:
|
| 663 |
+
soil += 0.12
|
| 664 |
+
if 40 <= features["saturation"] <= 130:
|
| 665 |
+
soil += 0.10
|
| 666 |
+
if 45 <= features["brightness"] <= 82:
|
| 667 |
+
soil += 0.10
|
| 668 |
+
scores["Bare Land/Soil Change"] = soil
|
| 669 |
+
|
| 670 |
+
# Normalize scores
|
| 671 |
+
max_score = max(scores.values()) if scores else 0
|
| 672 |
+
if max_score > 0:
|
| 673 |
+
for k in scores:
|
| 674 |
+
scores[k] /= max_score
|
| 675 |
+
|
| 676 |
+
best = max(scores, key=scores.get)
|
| 677 |
+
conf = scores[best]
|
| 678 |
+
|
| 679 |
+
if conf < 0.30:
|
| 680 |
+
return "Unclassified", conf
|
| 681 |
+
return best, min(conf, 1.0)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def classify_with_ensemble(image_region, bbox, num_sub=4):
|
| 685 |
+
"""Ensemble: classify full region + sub-regions, vote with confidence weighting."""
|
| 686 |
+
x, y, w, h = bbox
|
| 687 |
+
sub_boxes = [(x, y, w, h)] # full region
|
| 688 |
+
|
| 689 |
+
if w > 20 and h > 20:
|
| 690 |
+
hw, hh = w // 2, h // 2
|
| 691 |
+
sub_boxes += [
|
| 692 |
+
(x, y, hw, hh),
|
| 693 |
+
(x + hw, y, hw, hh),
|
| 694 |
+
(x, y + hh, hw, hh),
|
| 695 |
+
(x + hw, y + hh, hw, hh),
|
| 696 |
+
(x + w // 4, y + h // 4, hw, hh),
|
| 697 |
+
]
|
| 698 |
+
|
| 699 |
+
classifications = []
|
| 700 |
+
confidences = []
|
| 701 |
+
for sb in sub_boxes:
|
| 702 |
+
obj_type, conf = classify_object_type(image_region, sb)
|
| 703 |
+
if obj_type is None:
|
| 704 |
+
return None, 0.0 # transient → exclude
|
| 705 |
+
if obj_type != "Unclassified":
|
| 706 |
+
classifications.append(obj_type)
|
| 707 |
+
confidences.append(conf)
|
| 708 |
+
|
| 709 |
+
if not classifications:
|
| 710 |
+
return classify_object_type(image_region, (x, y, w, h))
|
| 711 |
+
|
| 712 |
+
# Weighted voting
|
| 713 |
+
weighted = {}
|
| 714 |
+
counts = Counter(classifications)
|
| 715 |
+
for ot, c in zip(classifications, confidences):
|
| 716 |
+
weighted[ot] = weighted.get(ot, 0) + c
|
| 717 |
+
|
| 718 |
+
best_type = max(weighted, key=weighted.get)
|
| 719 |
+
avg_conf = weighted[best_type] / counts[best_type]
|
| 720 |
+
|
| 721 |
+
if counts[best_type] / len(classifications) >= 0.6:
|
| 722 |
+
avg_conf = min(1.0, avg_conf * 1.15)
|
| 723 |
+
|
| 724 |
+
return best_type, avg_conf
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
# ---------------------------------------------------------------------------
|
| 728 |
+
# 11. Region analysis
|
| 729 |
+
# ---------------------------------------------------------------------------
|
| 730 |
+
|
| 731 |
+
def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True):
|
| 732 |
+
"""
|
| 733 |
+
Find connected change regions, classify as ground-level changes only.
|
| 734 |
+
Transient objects (people, cars, animals) are filtered out.
|
| 735 |
+
"""
|
| 736 |
+
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(change_mask, connectivity=8)
|
| 737 |
+
change_regions = []
|
| 738 |
+
region_id = 0
|
| 739 |
+
|
| 740 |
+
for i in range(1, num_labels):
|
| 741 |
+
area = stats[i, cv2.CC_STAT_AREA]
|
| 742 |
+
if area < min_area:
|
| 743 |
+
continue
|
| 744 |
+
|
| 745 |
+
x = stats[i, cv2.CC_STAT_LEFT]
|
| 746 |
+
y = stats[i, cv2.CC_STAT_TOP]
|
| 747 |
+
w = stats[i, cv2.CC_STAT_WIDTH]
|
| 748 |
+
h = stats[i, cv2.CC_STAT_HEIGHT]
|
| 749 |
+
cx, cy = centroids[i]
|
| 750 |
+
|
| 751 |
+
if use_ensemble and area > 500:
|
| 752 |
+
object_type, confidence = classify_with_ensemble(image, (x, y, w, h))
|
| 753 |
+
else:
|
| 754 |
+
object_type, confidence = classify_object_type(image, (x, y, w, h))
|
| 755 |
+
|
| 756 |
+
# None means transient / irrelevant → skip
|
| 757 |
+
if object_type is None:
|
| 758 |
+
continue
|
| 759 |
+
|
| 760 |
+
region_id += 1
|
| 761 |
+
change_regions.append({
|
| 762 |
+
"id": region_id,
|
| 763 |
+
"area": area,
|
| 764 |
+
"bbox": (x, y, w, h),
|
| 765 |
+
"center": (int(cx), int(cy)),
|
| 766 |
+
"object_type": object_type,
|
| 767 |
+
"confidence": confidence,
|
| 768 |
+
})
|
| 769 |
+
|
| 770 |
+
change_regions.sort(key=lambda r: r["area"], reverse=True)
|
| 771 |
+
return change_regions
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
# ---------------------------------------------------------------------------
|
| 775 |
+
# 12. Main pipeline
|
| 776 |
+
# ---------------------------------------------------------------------------
|
| 777 |
+
|
| 778 |
+
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
| 779 |
+
enable_registration=True, enable_normalization=True):
|
| 780 |
+
"""Run full detection pipeline; returns change_mask, result_image, stats, regions."""
|
| 781 |
+
before_array = preprocess_image(before_pil)
|
| 782 |
+
after_array = preprocess_image(after_pil)
|
| 783 |
+
|
| 784 |
+
if enable_registration:
|
| 785 |
+
before_array, after_array, _ = register_images(before_array, after_array)
|
| 786 |
+
if enable_normalization:
|
| 787 |
+
before_array, after_array = normalize_radiometry(before_array, after_array)
|
| 788 |
+
|
| 789 |
+
if method == "AI-Based Deep Learning":
|
| 790 |
+
change_mask = ai_deep_learning_method(before_array, after_array)
|
| 791 |
+
elif method == "Image Difference":
|
| 792 |
+
change_mask = image_difference_method(before_array, after_array)
|
| 793 |
+
elif method == "Feature-Based":
|
| 794 |
+
change_mask = feature_based_method(before_array, after_array)
|
| 795 |
+
else:
|
| 796 |
+
change_mask = hybrid_method(before_array, after_array)
|
| 797 |
+
|
| 798 |
+
# Classify regions
|
| 799 |
+
change_regions = analyze_change_regions(change_mask, after_array, min_area=80)
|
| 800 |
+
|
| 801 |
+
# Color-coded visualization using region classifications
|
| 802 |
+
result_image = visualize_changes(before_array, after_array, change_mask, regions=change_regions)
|
| 803 |
+
|
| 804 |
+
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
|
| 805 |
+
changed_pixels = int(np.sum(change_mask > 127))
|
| 806 |
+
change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
|
| 807 |
+
|
| 808 |
+
stats = {
|
| 809 |
+
"total_pixels": total_pixels,
|
| 810 |
+
"changed_pixels": changed_pixels,
|
| 811 |
+
"unchanged_pixels": total_pixels - changed_pixels,
|
| 812 |
+
"change_percentage": change_pct,
|
| 813 |
+
"image_width": change_mask.shape[1],
|
| 814 |
+
"image_height": change_mask.shape[0],
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
return change_mask, result_image, stats, change_regions
|
app/main.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import io
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from fastapi import FastAPI, Depends, File, Form, HTTPException, Request, UploadFile
|
| 11 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from .auth import (
|
| 18 |
+
COOKIE_NAME,
|
| 19 |
+
create_access_token,
|
| 20 |
+
get_password_hash,
|
| 21 |
+
get_user_by_email,
|
| 22 |
+
get_current_user,
|
| 23 |
+
get_user_from_token,
|
| 24 |
+
verify_password,
|
| 25 |
+
)
|
| 26 |
+
from .database import Base, engine, get_db
|
| 27 |
+
from .models import User, DetectionRun
|
| 28 |
+
from .detection_engine import run_detection
|
| 29 |
+
|
| 30 |
+
Base.metadata.create_all(bind=engine)
|
| 31 |
+
|
| 32 |
+
app = FastAPI(title="Satellite Change Detection", version="1.0.0")
|
| 33 |
+
|
| 34 |
+
# Mount static files
|
| 35 |
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 36 |
+
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
| 37 |
+
OVERLAYS_DIR = Path(__file__).resolve().parent.parent / "data" / "overlays"
|
| 38 |
+
OVERLAYS_DIR.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
|
| 40 |
+
if STATIC_DIR.exists():
|
| 41 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# --- Schemas ---
|
| 45 |
+
class UserCreate(BaseModel):
|
| 46 |
+
email: str
|
| 47 |
+
password: str
|
| 48 |
+
full_name: str = ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class UserLogin(BaseModel):
|
| 52 |
+
email: str
|
| 53 |
+
password: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class UserResponse(BaseModel):
|
| 57 |
+
id: int
|
| 58 |
+
email: str
|
| 59 |
+
full_name: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# --- Auth routes ---
|
| 63 |
+
def _auth_response(token: str, user: User):
|
| 64 |
+
"""JSON response with auth cookie so browser sends token on every request (e.g. POST /api/detect)."""
|
| 65 |
+
payload = {"access_token": token, "token_type": "bearer", "user": {"id": user.id, "email": user.email, "full_name": user.full_name}}
|
| 66 |
+
response = JSONResponse(content=payload)
|
| 67 |
+
response.set_cookie(
|
| 68 |
+
key=COOKIE_NAME,
|
| 69 |
+
value=token,
|
| 70 |
+
max_age=60 * 60 * 24 * 7, # 7 days
|
| 71 |
+
httponly=True,
|
| 72 |
+
samesite="lax",
|
| 73 |
+
path="/",
|
| 74 |
+
)
|
| 75 |
+
return response
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.post("/api/auth/register")
|
| 79 |
+
def register(data: UserCreate, db: Session = Depends(get_db)):
|
| 80 |
+
if get_user_by_email(db, data.email):
|
| 81 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 82 |
+
user = User(
|
| 83 |
+
email=data.email,
|
| 84 |
+
hashed_password=get_password_hash(data.password),
|
| 85 |
+
full_name=data.full_name,
|
| 86 |
+
)
|
| 87 |
+
db.add(user)
|
| 88 |
+
db.commit()
|
| 89 |
+
db.refresh(user)
|
| 90 |
+
token = create_access_token(data={"sub": str(user.id)})
|
| 91 |
+
return _auth_response(token, user)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.post("/api/auth/login")
|
| 95 |
+
def login(data: UserLogin, db: Session = Depends(get_db)):
|
| 96 |
+
user = get_user_by_email(db, data.email)
|
| 97 |
+
if not user or not verify_password(data.password, user.hashed_password):
|
| 98 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 99 |
+
token = create_access_token(data={"sub": str(user.id)})
|
| 100 |
+
return _auth_response(token, user)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.post("/api/auth/logout")
|
| 104 |
+
def logout():
|
| 105 |
+
"""Clear auth cookie so subsequent requests are unauthenticated."""
|
| 106 |
+
response = JSONResponse(content={"ok": True})
|
| 107 |
+
response.delete_cookie(COOKIE_NAME, path="/")
|
| 108 |
+
return response
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.get("/api/me")
|
| 112 |
+
def me(user: Optional[User] = Depends(get_current_user)):
|
| 113 |
+
if not user:
|
| 114 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 115 |
+
return {"id": user.id, "email": user.email, "full_name": user.full_name}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.get("/api/debug-auth")
|
| 119 |
+
def debug_auth(request: Request, user: Optional[User] = Depends(get_current_user)):
|
| 120 |
+
"""Debug endpoint to see what auth info the server receives."""
|
| 121 |
+
auth_header = request.headers.get("authorization", "")
|
| 122 |
+
cookie_val = request.cookies.get(COOKIE_NAME, "")
|
| 123 |
+
return {
|
| 124 |
+
"has_auth_header": bool(auth_header),
|
| 125 |
+
"auth_header_preview": auth_header[:40] + "..." if len(auth_header) > 40 else auth_header,
|
| 126 |
+
"has_cookie": bool(cookie_val),
|
| 127 |
+
"cookie_preview": cookie_val[:20] + "..." if len(cookie_val) > 20 else cookie_val,
|
| 128 |
+
"authenticated": user is not None,
|
| 129 |
+
"user_id": user.id if user else None,
|
| 130 |
+
"user_email": user.email if user else None,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# --- Detection route ---
|
| 135 |
+
@app.post("/api/detect")
|
| 136 |
+
async def detect(
|
| 137 |
+
request: Request,
|
| 138 |
+
before: UploadFile = File(...),
|
| 139 |
+
after: UploadFile = File(...),
|
| 140 |
+
method: str = Form("AI-Based Deep Learning"),
|
| 141 |
+
title: str = Form("Untitled run"),
|
| 142 |
+
enable_registration: bool = Form(True),
|
| 143 |
+
enable_normalization: bool = Form(True),
|
| 144 |
+
access_token: Optional[str] = Form(None),
|
| 145 |
+
db: Session = Depends(get_db),
|
| 146 |
+
):
|
| 147 |
+
# Resolve user from token (header, cookie, or form - in case browser strips headers for multipart)
|
| 148 |
+
token = None
|
| 149 |
+
auth_header = request.headers.get("authorization") or request.headers.get("Authorization")
|
| 150 |
+
if auth_header and auth_header.lower().startswith("bearer "):
|
| 151 |
+
token = auth_header[7:].strip()
|
| 152 |
+
if not token:
|
| 153 |
+
token = request.cookies.get(COOKIE_NAME)
|
| 154 |
+
if not token:
|
| 155 |
+
token = access_token
|
| 156 |
+
user = get_user_from_token(token, db) if token else None
|
| 157 |
+
if not user:
|
| 158 |
+
raise HTTPException(status_code=401, detail="Login required")
|
| 159 |
+
try:
|
| 160 |
+
before_pil = Image.open(io.BytesIO(await before.read())).convert("RGB")
|
| 161 |
+
after_pil = Image.open(io.BytesIO(await after.read())).convert("RGB")
|
| 162 |
+
except Exception as e:
|
| 163 |
+
raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
|
| 164 |
+
change_mask, result_image, stats, change_regions = run_detection(
|
| 165 |
+
before_pil, after_pil, method=method, enable_registration=enable_registration, enable_normalization=enable_normalization
|
| 166 |
+
)
|
| 167 |
+
# Save overlay to disk and store path (optional)
|
| 168 |
+
overlay_filename = f"{user.id}_{uuid.uuid4().hex}.png"
|
| 169 |
+
overlay_path = OVERLAYS_DIR / overlay_filename
|
| 170 |
+
Image.fromarray(result_image).save(overlay_path)
|
| 171 |
+
relative_overlay = f"overlays/{overlay_filename}"
|
| 172 |
+
regions_serializable = [
|
| 173 |
+
{
|
| 174 |
+
"id": int(r["id"]),
|
| 175 |
+
"area": int(r["area"]),
|
| 176 |
+
"center": {"x": int(r["center"][0]), "y": int(r["center"][1])},
|
| 177 |
+
"bbox": {"x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3])},
|
| 178 |
+
"objectType": str(r["object_type"]),
|
| 179 |
+
"confidence": float(r["confidence"]),
|
| 180 |
+
}
|
| 181 |
+
for r in change_regions
|
| 182 |
+
]
|
| 183 |
+
total_px = int(stats["total_pixels"])
|
| 184 |
+
changed_px = int(stats["changed_pixels"])
|
| 185 |
+
unchanged_px = int(stats["unchanged_pixels"])
|
| 186 |
+
change_pct = float(stats["change_percentage"])
|
| 187 |
+
run = DetectionRun(
|
| 188 |
+
user_id=user.id,
|
| 189 |
+
title=title,
|
| 190 |
+
method=method,
|
| 191 |
+
total_pixels=total_px,
|
| 192 |
+
changed_pixels=changed_px,
|
| 193 |
+
change_percentage=change_pct,
|
| 194 |
+
regions_count=len(change_regions),
|
| 195 |
+
overlay_path=relative_overlay,
|
| 196 |
+
regions_json=json.dumps(regions_serializable),
|
| 197 |
+
)
|
| 198 |
+
db.add(run)
|
| 199 |
+
db.commit()
|
| 200 |
+
db.refresh(run)
|
| 201 |
+
# Base64 overlay for immediate display
|
| 202 |
+
buf = io.BytesIO()
|
| 203 |
+
Image.fromarray(result_image).save(buf, format="PNG")
|
| 204 |
+
buf.seek(0)
|
| 205 |
+
overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
|
| 206 |
+
return {
|
| 207 |
+
"id": run.id,
|
| 208 |
+
"title": run.title,
|
| 209 |
+
"method": run.method,
|
| 210 |
+
"statistics": {
|
| 211 |
+
"totalPixels": total_px,
|
| 212 |
+
"changedPixels": changed_px,
|
| 213 |
+
"unchangedPixels": unchanged_px,
|
| 214 |
+
"changePercentage": change_pct,
|
| 215 |
+
},
|
| 216 |
+
"regions": regions_serializable,
|
| 217 |
+
"overlayBase64Png": overlay_b64,
|
| 218 |
+
"overlayUrl": f"/api/overlay/{relative_overlay}",
|
| 219 |
+
"createdAt": run.created_at.isoformat(),
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@app.get("/api/overlay/{path:path}")
|
| 224 |
+
def serve_overlay(path: str):
|
| 225 |
+
# Restrict to overlays directory
|
| 226 |
+
full = (OVERLAYS_DIR.parent / path).resolve()
|
| 227 |
+
base = OVERLAYS_DIR.parent.resolve()
|
| 228 |
+
try:
|
| 229 |
+
full.relative_to(base)
|
| 230 |
+
except ValueError:
|
| 231 |
+
raise HTTPException(404)
|
| 232 |
+
if not full.exists() or not full.is_file():
|
| 233 |
+
raise HTTPException(404)
|
| 234 |
+
return FileResponse(full, media_type="image/png")
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# --- History ---
|
| 238 |
+
@app.get("/api/history")
|
| 239 |
+
def history(
|
| 240 |
+
user: Optional[User] = Depends(get_current_user),
|
| 241 |
+
db: Session = Depends(get_db),
|
| 242 |
+
):
|
| 243 |
+
if not user:
|
| 244 |
+
raise HTTPException(status_code=401, detail="Login required")
|
| 245 |
+
runs = db.query(DetectionRun).filter(DetectionRun.user_id == user.id).order_by(DetectionRun.created_at.desc()).limit(100).all()
|
| 246 |
+
return [
|
| 247 |
+
{
|
| 248 |
+
"id": r.id,
|
| 249 |
+
"title": r.title,
|
| 250 |
+
"method": r.method,
|
| 251 |
+
"changePercentage": r.change_percentage,
|
| 252 |
+
"regionsCount": r.regions_count,
|
| 253 |
+
"overlayUrl": f"/api/overlay/{r.overlay_path}" if r.overlay_path else None,
|
| 254 |
+
"createdAt": r.created_at.isoformat(),
|
| 255 |
+
}
|
| 256 |
+
for r in runs
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# --- Delete history run ---
|
| 261 |
+
@app.delete("/api/history/{run_id}")
|
| 262 |
+
def delete_run(
|
| 263 |
+
run_id: int,
|
| 264 |
+
user: Optional[User] = Depends(get_current_user),
|
| 265 |
+
db: Session = Depends(get_db),
|
| 266 |
+
):
|
| 267 |
+
if not user:
|
| 268 |
+
raise HTTPException(status_code=401, detail="Login required")
|
| 269 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
|
| 270 |
+
if not run:
|
| 271 |
+
raise HTTPException(status_code=404, detail="Run not found")
|
| 272 |
+
# Delete overlay file if it exists
|
| 273 |
+
if run.overlay_path:
|
| 274 |
+
overlay_file = OVERLAYS_DIR.parent / run.overlay_path
|
| 275 |
+
if overlay_file.exists():
|
| 276 |
+
overlay_file.unlink(missing_ok=True)
|
| 277 |
+
db.delete(run)
|
| 278 |
+
db.commit()
|
| 279 |
+
return {"ok": True, "deleted_id": run_id}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# --- Serve SPA ---
|
| 283 |
+
@app.get("/", response_class=HTMLResponse)
|
| 284 |
+
def index():
|
| 285 |
+
index_file = TEMPLATES_DIR / "index.html"
|
| 286 |
+
if not index_file.exists():
|
| 287 |
+
return HTMLResponse("<h1>Satellite Change Detection</h1><p>Create <code>templates/index.html</code> and <code>static/</code>.</p>")
|
| 288 |
+
return FileResponse(index_file)
|
app/models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Float
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
|
| 5 |
+
from .database import Base
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 12 |
+
email = Column(String(255), unique=True, index=True, nullable=False)
|
| 13 |
+
hashed_password = Column(String(255), nullable=False)
|
| 14 |
+
full_name = Column(String(255), default="")
|
| 15 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 16 |
+
|
| 17 |
+
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DetectionRun(Base):
|
| 21 |
+
__tablename__ = "detection_runs"
|
| 22 |
+
|
| 23 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 24 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 25 |
+
title = Column(String(255), default="Untitled run")
|
| 26 |
+
method = Column(String(64), nullable=False)
|
| 27 |
+
total_pixels = Column(Integer, nullable=False)
|
| 28 |
+
changed_pixels = Column(Integer, nullable=False)
|
| 29 |
+
change_percentage = Column(Float, nullable=False)
|
| 30 |
+
regions_count = Column(Integer, default=0)
|
| 31 |
+
overlay_path = Column(String(512), default="") # optional: path to saved overlay image
|
| 32 |
+
regions_json = Column(Text, default="[]") # JSON list of regions
|
| 33 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 34 |
+
|
| 35 |
+
user = relationship("User", back_populates="detections")
|
data/overlays/.gitkeep
ADDED
|
File without changes
|
render.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Render Blueprint — one-click deploy for SatDetect
|
| 2 |
+
# https://render.com/docs/blueprint-spec
|
| 3 |
+
|
| 4 |
+
databases:
|
| 5 |
+
- name: satdetect-db
|
| 6 |
+
plan: free
|
| 7 |
+
databaseName: satdetect
|
| 8 |
+
user: satdetect
|
| 9 |
+
|
| 10 |
+
services:
|
| 11 |
+
- type: web
|
| 12 |
+
name: satdetect
|
| 13 |
+
runtime: docker
|
| 14 |
+
plan: free
|
| 15 |
+
dockerfilePath: ./Dockerfile
|
| 16 |
+
envVars:
|
| 17 |
+
- key: DATABASE_URL
|
| 18 |
+
fromDatabase:
|
| 19 |
+
name: satdetect-db
|
| 20 |
+
property: connectionString
|
| 21 |
+
- key: SECRET_KEY
|
| 22 |
+
generateValue: true
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.104.0
|
| 2 |
+
uvicorn[standard]>=0.24.0
|
| 3 |
+
gunicorn>=21.2.0
|
| 4 |
+
python-multipart>=0.0.6
|
| 5 |
+
sqlalchemy>=2.0.0
|
| 6 |
+
psycopg2-binary>=2.9.9
|
| 7 |
+
python-jose[cryptography]>=3.3.0
|
| 8 |
+
passlib[bcrypt]>=1.7.4
|
| 9 |
+
pillow>=10.0.0
|
| 10 |
+
numpy>=1.24.0
|
| 11 |
+
opencv-python-headless>=4.8.0
|
| 12 |
+
scikit-learn>=1.3.0
|
| 13 |
+
scipy>=1.11.0
|
run.bat
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
cd /d "%~dp0"
|
| 3 |
+
echo Starting Satellite Change Detection...
|
| 4 |
+
echo.
|
| 5 |
+
echo Open in your browser: http://localhost:8000
|
| 6 |
+
echo.
|
| 7 |
+
start http://localhost:8000
|
| 8 |
+
uvicorn app.main:app --host 127.0.0.1 --port 8000
|
run.ps1
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Run from change_detection_webapp folder
|
| 2 |
+
Set-Location $PSScriptRoot
|
| 3 |
+
Write-Host "Starting Satellite Change Detection..." -ForegroundColor Cyan
|
| 4 |
+
Write-Host ""
|
| 5 |
+
Write-Host "Open in your browser: http://localhost:8000" -ForegroundColor Green
|
| 6 |
+
Write-Host ""
|
| 7 |
+
Start-Process "http://localhost:8000"
|
| 8 |
+
uvicorn app.main:app --host 127.0.0.1 --port 8000
|
static/css/style.css
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* SatDetect — Modern dark design system */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 3 |
+
|
| 4 |
+
:root {
|
| 5 |
+
--bg-deep: #080b12;
|
| 6 |
+
--bg-surface: #0f1320;
|
| 7 |
+
--bg-elevated: #161c2e;
|
| 8 |
+
--bg-card: #121828;
|
| 9 |
+
--bg-hover: #1c2540;
|
| 10 |
+
--border: rgba(148, 163, 184, 0.10);
|
| 11 |
+
--border-hover: rgba(148, 163, 184, 0.22);
|
| 12 |
+
--border-focus: rgba(45, 212, 191, 0.5);
|
| 13 |
+
--text: #f1f5f9;
|
| 14 |
+
--text-muted: #94a3b8;
|
| 15 |
+
--text-dim: #64748b;
|
| 16 |
+
--accent: #2dd4bf;
|
| 17 |
+
--accent-dim: #14b8a6;
|
| 18 |
+
--accent-glow: rgba(45, 212, 191, 0.2);
|
| 19 |
+
--danger: #f87171;
|
| 20 |
+
--danger-dim: #ef4444;
|
| 21 |
+
--danger-glow: rgba(248, 113, 113, 0.15);
|
| 22 |
+
--success: #34d399;
|
| 23 |
+
--radius: 14px;
|
| 24 |
+
--radius-sm: 10px;
|
| 25 |
+
--radius-xs: 6px;
|
| 26 |
+
--shadow: 0 4px 32px rgba(0, 0, 0, 0.45);
|
| 27 |
+
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.3);
|
| 28 |
+
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
| 29 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 30 |
+
--transition: 0.2s ease;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
*, *::before, *::after {
|
| 34 |
+
box-sizing: border-box;
|
| 35 |
+
margin: 0;
|
| 36 |
+
padding: 0;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
body {
|
| 40 |
+
font-family: var(--font-sans);
|
| 41 |
+
background: var(--bg-deep);
|
| 42 |
+
color: var(--text);
|
| 43 |
+
min-height: 100vh;
|
| 44 |
+
line-height: 1.6;
|
| 45 |
+
-webkit-font-smoothing: antialiased;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
body::before {
|
| 49 |
+
content: '';
|
| 50 |
+
position: fixed;
|
| 51 |
+
inset: 0;
|
| 52 |
+
background-image:
|
| 53 |
+
radial-gradient(ellipse at 20% 0%, rgba(45, 212, 191, 0.06) 0%, transparent 50%),
|
| 54 |
+
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.04) 0%, transparent 50%);
|
| 55 |
+
pointer-events: none;
|
| 56 |
+
z-index: 0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.app {
|
| 60 |
+
position: relative;
|
| 61 |
+
z-index: 1;
|
| 62 |
+
max-width: 1100px;
|
| 63 |
+
margin: 0 auto;
|
| 64 |
+
padding: 1.5rem;
|
| 65 |
+
min-height: 100vh;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* Views */
|
| 69 |
+
.view { display: none; animation: fadeSlideIn 0.35s ease; }
|
| 70 |
+
.view.active { display: block; }
|
| 71 |
+
|
| 72 |
+
@keyframes fadeSlideIn {
|
| 73 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 74 |
+
to { opacity: 1; transform: translateY(0); }
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* ---- Auth pages ---- */
|
| 78 |
+
.auth-container { max-width: 420px; margin: 3rem auto; }
|
| 79 |
+
|
| 80 |
+
.auth-logo {
|
| 81 |
+
display: flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
justify-content: center;
|
| 84 |
+
gap: 0.6rem;
|
| 85 |
+
margin-bottom: 2rem;
|
| 86 |
+
font-size: 1.3rem;
|
| 87 |
+
font-weight: 700;
|
| 88 |
+
letter-spacing: -0.03em;
|
| 89 |
+
color: var(--text);
|
| 90 |
+
}
|
| 91 |
+
.auth-logo-icon {
|
| 92 |
+
display: flex;
|
| 93 |
+
align-items: center;
|
| 94 |
+
justify-content: center;
|
| 95 |
+
width: 48px;
|
| 96 |
+
height: 48px;
|
| 97 |
+
border-radius: 14px;
|
| 98 |
+
background: linear-gradient(135deg, var(--accent), #6366f1);
|
| 99 |
+
color: #fff;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.auth-container .card { padding: 2rem; }
|
| 103 |
+
.auth-container h2 {
|
| 104 |
+
font-size: 1.3rem;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
margin-bottom: 0.35rem;
|
| 107 |
+
}
|
| 108 |
+
.auth-container .sub {
|
| 109 |
+
color: var(--text-muted);
|
| 110 |
+
font-size: 0.9rem;
|
| 111 |
+
margin-bottom: 1.75rem;
|
| 112 |
+
}
|
| 113 |
+
.auth-container .form-group:last-of-type { margin-bottom: 1.75rem; }
|
| 114 |
+
.toggle-auth {
|
| 115 |
+
text-align: center;
|
| 116 |
+
margin-top: 1.5rem;
|
| 117 |
+
color: var(--text-muted);
|
| 118 |
+
font-size: 0.9rem;
|
| 119 |
+
}
|
| 120 |
+
.toggle-auth a {
|
| 121 |
+
color: var(--accent);
|
| 122 |
+
text-decoration: none;
|
| 123 |
+
font-weight: 500;
|
| 124 |
+
}
|
| 125 |
+
.toggle-auth a:hover { text-decoration: underline; }
|
| 126 |
+
|
| 127 |
+
/* ---- Cards ---- */
|
| 128 |
+
.card {
|
| 129 |
+
background: var(--bg-card);
|
| 130 |
+
border: 1px solid var(--border);
|
| 131 |
+
border-radius: var(--radius);
|
| 132 |
+
padding: 1.5rem;
|
| 133 |
+
box-shadow: var(--shadow);
|
| 134 |
+
}
|
| 135 |
+
.card + .card { margin-top: 1.25rem; }
|
| 136 |
+
|
| 137 |
+
.card-header {
|
| 138 |
+
display: flex;
|
| 139 |
+
align-items: center;
|
| 140 |
+
gap: 0.6rem;
|
| 141 |
+
margin-bottom: 1.25rem;
|
| 142 |
+
padding-bottom: 0.75rem;
|
| 143 |
+
border-bottom: 1px solid var(--border);
|
| 144 |
+
}
|
| 145 |
+
.card-header h3 {
|
| 146 |
+
font-size: 1rem;
|
| 147 |
+
font-weight: 600;
|
| 148 |
+
letter-spacing: -0.01em;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* ---- Forms ---- */
|
| 152 |
+
.form-group { margin-bottom: 1.25rem; }
|
| 153 |
+
.form-group label {
|
| 154 |
+
display: block;
|
| 155 |
+
font-size: 0.8rem;
|
| 156 |
+
font-weight: 500;
|
| 157 |
+
color: var(--text-muted);
|
| 158 |
+
margin-bottom: 0.4rem;
|
| 159 |
+
text-transform: uppercase;
|
| 160 |
+
letter-spacing: 0.04em;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
input[type="email"],
|
| 164 |
+
input[type="password"],
|
| 165 |
+
input[type="text"],
|
| 166 |
+
select,
|
| 167 |
+
textarea {
|
| 168 |
+
width: 100%;
|
| 169 |
+
padding: 0.7rem 0.9rem;
|
| 170 |
+
font-family: var(--font-sans);
|
| 171 |
+
font-size: 0.95rem;
|
| 172 |
+
color: var(--text);
|
| 173 |
+
background: var(--bg-elevated);
|
| 174 |
+
border: 1px solid var(--border);
|
| 175 |
+
border-radius: var(--radius-sm);
|
| 176 |
+
transition: border-color var(--transition), box-shadow var(--transition);
|
| 177 |
+
}
|
| 178 |
+
input:focus, select:focus, textarea:focus {
|
| 179 |
+
outline: none;
|
| 180 |
+
border-color: var(--border-focus);
|
| 181 |
+
box-shadow: 0 0 0 3px var(--accent-glow);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.checkbox-group label {
|
| 185 |
+
display: inline-flex;
|
| 186 |
+
align-items: center;
|
| 187 |
+
gap: 0.4rem;
|
| 188 |
+
font-size: 0.85rem;
|
| 189 |
+
color: var(--text-muted);
|
| 190 |
+
cursor: pointer;
|
| 191 |
+
text-transform: none;
|
| 192 |
+
letter-spacing: 0;
|
| 193 |
+
}
|
| 194 |
+
.checkbox-group input[type="checkbox"] {
|
| 195 |
+
accent-color: var(--accent);
|
| 196 |
+
width: 16px;
|
| 197 |
+
height: 16px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.dim { color: var(--text-dim); }
|
| 201 |
+
|
| 202 |
+
/* ---- Buttons ---- */
|
| 203 |
+
.btn {
|
| 204 |
+
display: inline-flex;
|
| 205 |
+
align-items: center;
|
| 206 |
+
justify-content: center;
|
| 207 |
+
gap: 0.5rem;
|
| 208 |
+
padding: 0.65rem 1.3rem;
|
| 209 |
+
font-family: var(--font-sans);
|
| 210 |
+
font-size: 0.875rem;
|
| 211 |
+
font-weight: 500;
|
| 212 |
+
border: none;
|
| 213 |
+
border-radius: var(--radius-sm);
|
| 214 |
+
cursor: pointer;
|
| 215 |
+
transition: all var(--transition);
|
| 216 |
+
white-space: nowrap;
|
| 217 |
+
}
|
| 218 |
+
.btn:active { transform: scale(0.97); }
|
| 219 |
+
|
| 220 |
+
.btn-primary {
|
| 221 |
+
background: linear-gradient(135deg, var(--accent), #14b8a6);
|
| 222 |
+
color: var(--bg-deep);
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
}
|
| 225 |
+
.btn-primary:hover { filter: brightness(1.1); box-shadow: 0 4px 20px var(--accent-glow); }
|
| 226 |
+
|
| 227 |
+
.btn-secondary {
|
| 228 |
+
background: var(--bg-elevated);
|
| 229 |
+
color: var(--text);
|
| 230 |
+
border: 1px solid var(--border);
|
| 231 |
+
}
|
| 232 |
+
.btn-secondary:hover { background: var(--bg-hover); border-color: var(--border-hover); }
|
| 233 |
+
|
| 234 |
+
.btn-ghost {
|
| 235 |
+
background: transparent;
|
| 236 |
+
color: var(--text-muted);
|
| 237 |
+
padding: 0.5rem 0.75rem;
|
| 238 |
+
}
|
| 239 |
+
.btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.04); }
|
| 240 |
+
|
| 241 |
+
.btn-danger {
|
| 242 |
+
background: var(--danger);
|
| 243 |
+
color: #fff;
|
| 244 |
+
font-weight: 600;
|
| 245 |
+
}
|
| 246 |
+
.btn-danger:hover { background: var(--danger-dim); box-shadow: 0 4px 16px var(--danger-glow); }
|
| 247 |
+
|
| 248 |
+
.btn-icon {
|
| 249 |
+
background: transparent;
|
| 250 |
+
color: var(--text-dim);
|
| 251 |
+
padding: 0.4rem;
|
| 252 |
+
border-radius: var(--radius-xs);
|
| 253 |
+
border: none;
|
| 254 |
+
cursor: pointer;
|
| 255 |
+
display: inline-flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
transition: all var(--transition);
|
| 258 |
+
}
|
| 259 |
+
.btn-icon:hover { color: var(--danger); background: var(--danger-glow); }
|
| 260 |
+
|
| 261 |
+
.btn-block { width: 100%; }
|
| 262 |
+
.btn-sm { font-size: 0.8rem; padding: 0.45rem 0.8rem; }
|
| 263 |
+
.btn-lg { padding: 0.8rem 1.6rem; font-size: 0.95rem; }
|
| 264 |
+
|
| 265 |
+
/* ---- Nav ---- */
|
| 266 |
+
.nav {
|
| 267 |
+
display: flex;
|
| 268 |
+
align-items: center;
|
| 269 |
+
justify-content: space-between;
|
| 270 |
+
margin-bottom: 2rem;
|
| 271 |
+
padding-bottom: 1rem;
|
| 272 |
+
border-bottom: 1px solid var(--border);
|
| 273 |
+
}
|
| 274 |
+
.nav-brand {
|
| 275 |
+
font-weight: 700;
|
| 276 |
+
font-size: 1.05rem;
|
| 277 |
+
display: flex;
|
| 278 |
+
align-items: center;
|
| 279 |
+
gap: 0.5rem;
|
| 280 |
+
letter-spacing: -0.02em;
|
| 281 |
+
}
|
| 282 |
+
.nav-user {
|
| 283 |
+
display: flex;
|
| 284 |
+
align-items: center;
|
| 285 |
+
gap: 0.75rem;
|
| 286 |
+
}
|
| 287 |
+
.user-badge {
|
| 288 |
+
font-size: 0.8rem;
|
| 289 |
+
color: var(--text-muted);
|
| 290 |
+
background: var(--bg-elevated);
|
| 291 |
+
padding: 0.3rem 0.7rem;
|
| 292 |
+
border-radius: 20px;
|
| 293 |
+
border: 1px solid var(--border);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* ---- Page header ---- */
|
| 297 |
+
.page-header { margin-bottom: 1.75rem; }
|
| 298 |
+
.page-header h1 {
|
| 299 |
+
font-size: 1.6rem;
|
| 300 |
+
font-weight: 700;
|
| 301 |
+
letter-spacing: -0.03em;
|
| 302 |
+
}
|
| 303 |
+
.page-header p {
|
| 304 |
+
color: var(--text-muted);
|
| 305 |
+
margin-top: 0.3rem;
|
| 306 |
+
font-size: 0.9rem;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
/* ---- Upload zones ---- */
|
| 310 |
+
.upload-grid {
|
| 311 |
+
display: grid;
|
| 312 |
+
grid-template-columns: 1fr 1fr;
|
| 313 |
+
gap: 1rem;
|
| 314 |
+
margin-bottom: 1.25rem;
|
| 315 |
+
}
|
| 316 |
+
@media (max-width: 640px) {
|
| 317 |
+
.upload-grid { grid-template-columns: 1fr; }
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.upload-zone {
|
| 321 |
+
position: relative;
|
| 322 |
+
border: 2px dashed var(--border);
|
| 323 |
+
border-radius: var(--radius);
|
| 324 |
+
padding: 1.75rem 1.5rem;
|
| 325 |
+
text-align: center;
|
| 326 |
+
background: var(--bg-elevated);
|
| 327 |
+
transition: all var(--transition);
|
| 328 |
+
cursor: pointer;
|
| 329 |
+
}
|
| 330 |
+
.upload-zone:hover, .upload-zone.dragover {
|
| 331 |
+
border-color: var(--accent);
|
| 332 |
+
background: rgba(45, 212, 191, 0.04);
|
| 333 |
+
box-shadow: 0 0 0 4px var(--accent-glow);
|
| 334 |
+
}
|
| 335 |
+
.upload-zone input[type="file"] {
|
| 336 |
+
position: absolute;
|
| 337 |
+
width: 0;
|
| 338 |
+
height: 0;
|
| 339 |
+
opacity: 0;
|
| 340 |
+
}
|
| 341 |
+
.upload-icon {
|
| 342 |
+
color: var(--text-dim);
|
| 343 |
+
margin-bottom: 0.5rem;
|
| 344 |
+
transition: color var(--transition);
|
| 345 |
+
}
|
| 346 |
+
.upload-zone:hover .upload-icon { color: var(--accent); }
|
| 347 |
+
.upload-zone label {
|
| 348 |
+
cursor: pointer;
|
| 349 |
+
display: block;
|
| 350 |
+
color: var(--text-muted);
|
| 351 |
+
font-size: 0.85rem;
|
| 352 |
+
}
|
| 353 |
+
.upload-zone .filename {
|
| 354 |
+
margin-top: 0.4rem;
|
| 355 |
+
font-size: 0.8rem;
|
| 356 |
+
color: var(--accent);
|
| 357 |
+
font-family: var(--font-mono);
|
| 358 |
+
}
|
| 359 |
+
.upload-preview {
|
| 360 |
+
margin-top: 0.75rem;
|
| 361 |
+
max-height: 100px;
|
| 362 |
+
max-width: 100%;
|
| 363 |
+
border-radius: var(--radius-xs);
|
| 364 |
+
object-fit: cover;
|
| 365 |
+
border: 1px solid var(--border);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.options-row {
|
| 369 |
+
display: flex;
|
| 370 |
+
flex-wrap: wrap;
|
| 371 |
+
gap: 0.75rem;
|
| 372 |
+
align-items: flex-end;
|
| 373 |
+
}
|
| 374 |
+
.options-row .form-group {
|
| 375 |
+
margin-bottom: 0;
|
| 376 |
+
min-width: 160px;
|
| 377 |
+
flex: 1;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.run-btn-wrap {
|
| 381 |
+
margin-top: 1.25rem;
|
| 382 |
+
display: flex;
|
| 383 |
+
align-items: center;
|
| 384 |
+
gap: 1rem;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* ---- Result stats ---- */
|
| 388 |
+
.result-stats {
|
| 389 |
+
display: grid;
|
| 390 |
+
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
| 391 |
+
gap: 0.75rem;
|
| 392 |
+
margin-bottom: 1.25rem;
|
| 393 |
+
}
|
| 394 |
+
.stat-box {
|
| 395 |
+
background: var(--bg-elevated);
|
| 396 |
+
border: 1px solid var(--border);
|
| 397 |
+
border-radius: var(--radius-sm);
|
| 398 |
+
padding: 0.85rem 0.75rem;
|
| 399 |
+
text-align: center;
|
| 400 |
+
transition: border-color var(--transition);
|
| 401 |
+
}
|
| 402 |
+
.stat-box:hover { border-color: var(--border-hover); }
|
| 403 |
+
.stat-box .value {
|
| 404 |
+
font-size: 1.35rem;
|
| 405 |
+
font-weight: 700;
|
| 406 |
+
color: var(--accent);
|
| 407 |
+
font-family: var(--font-mono);
|
| 408 |
+
}
|
| 409 |
+
.stat-box .label {
|
| 410 |
+
font-size: 0.7rem;
|
| 411 |
+
color: var(--text-dim);
|
| 412 |
+
text-transform: uppercase;
|
| 413 |
+
letter-spacing: 0.06em;
|
| 414 |
+
margin-top: 0.2rem;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* ---- Compare slider ---- */
|
| 418 |
+
.compare-slider {
|
| 419 |
+
position: relative;
|
| 420 |
+
width: 100%;
|
| 421 |
+
overflow: hidden;
|
| 422 |
+
border-radius: var(--radius);
|
| 423 |
+
border: 1px solid var(--border);
|
| 424 |
+
margin-top: 0.75rem;
|
| 425 |
+
cursor: col-resize;
|
| 426 |
+
user-select: none;
|
| 427 |
+
-webkit-user-select: none;
|
| 428 |
+
background: var(--bg-elevated);
|
| 429 |
+
line-height: 0;
|
| 430 |
+
}
|
| 431 |
+
.compare-slider img {
|
| 432 |
+
display: block;
|
| 433 |
+
width: 100%;
|
| 434 |
+
height: auto;
|
| 435 |
+
pointer-events: none;
|
| 436 |
+
}
|
| 437 |
+
.compare-before {
|
| 438 |
+
position: relative;
|
| 439 |
+
width: 100%;
|
| 440 |
+
}
|
| 441 |
+
.compare-after {
|
| 442 |
+
position: absolute;
|
| 443 |
+
top: 0; left: 0;
|
| 444 |
+
width: 100%; height: 100%;
|
| 445 |
+
overflow: hidden;
|
| 446 |
+
clip-path: inset(0 0 0 50%);
|
| 447 |
+
}
|
| 448 |
+
.compare-after img {
|
| 449 |
+
position: absolute;
|
| 450 |
+
top: 0; left: 0;
|
| 451 |
+
width: 100%; height: 100%;
|
| 452 |
+
object-fit: cover;
|
| 453 |
+
}
|
| 454 |
+
.compare-handle {
|
| 455 |
+
position: absolute;
|
| 456 |
+
top: 0; bottom: 0;
|
| 457 |
+
left: 50%;
|
| 458 |
+
width: 3px;
|
| 459 |
+
display: flex;
|
| 460 |
+
flex-direction: column;
|
| 461 |
+
align-items: center;
|
| 462 |
+
justify-content: center;
|
| 463 |
+
transform: translateX(-50%);
|
| 464 |
+
z-index: 10;
|
| 465 |
+
pointer-events: none;
|
| 466 |
+
}
|
| 467 |
+
.compare-handle-line {
|
| 468 |
+
flex: 1;
|
| 469 |
+
width: 2px;
|
| 470 |
+
background: var(--accent);
|
| 471 |
+
box-shadow: 0 0 10px var(--accent-glow);
|
| 472 |
+
}
|
| 473 |
+
.compare-handle-knob {
|
| 474 |
+
width: 36px; height: 36px;
|
| 475 |
+
border-radius: 50%;
|
| 476 |
+
background: var(--accent);
|
| 477 |
+
color: var(--bg-deep);
|
| 478 |
+
display: flex;
|
| 479 |
+
align-items: center;
|
| 480 |
+
justify-content: center;
|
| 481 |
+
box-shadow: 0 2px 16px rgba(0,0,0,0.5), 0 0 20px var(--accent-glow);
|
| 482 |
+
flex-shrink: 0;
|
| 483 |
+
}
|
| 484 |
+
.compare-label {
|
| 485 |
+
position: absolute;
|
| 486 |
+
top: 10px;
|
| 487 |
+
padding: 3px 10px;
|
| 488 |
+
border-radius: 20px;
|
| 489 |
+
font-size: 0.7rem;
|
| 490 |
+
font-weight: 600;
|
| 491 |
+
text-transform: uppercase;
|
| 492 |
+
letter-spacing: 0.06em;
|
| 493 |
+
pointer-events: none;
|
| 494 |
+
line-height: 1.4;
|
| 495 |
+
}
|
| 496 |
+
.compare-label-left {
|
| 497 |
+
left: 10px;
|
| 498 |
+
background: rgba(10, 14, 23, 0.8);
|
| 499 |
+
color: var(--text);
|
| 500 |
+
backdrop-filter: blur(6px);
|
| 501 |
+
}
|
| 502 |
+
.compare-label-right {
|
| 503 |
+
right: 10px;
|
| 504 |
+
background: rgba(248, 113, 113, 0.85);
|
| 505 |
+
color: #fff;
|
| 506 |
+
backdrop-filter: blur(6px);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
/* ---- Regions table ---- */
|
| 510 |
+
.regions-table-wrap { overflow-x: auto; margin-top: 1.25rem; }
|
| 511 |
+
.regions-table {
|
| 512 |
+
width: 100%;
|
| 513 |
+
border-collapse: collapse;
|
| 514 |
+
font-size: 0.85rem;
|
| 515 |
+
}
|
| 516 |
+
.regions-table th, .regions-table td {
|
| 517 |
+
padding: 0.6rem 0.75rem;
|
| 518 |
+
text-align: left;
|
| 519 |
+
border-bottom: 1px solid var(--border);
|
| 520 |
+
}
|
| 521 |
+
.regions-table th {
|
| 522 |
+
color: var(--text-dim);
|
| 523 |
+
font-weight: 600;
|
| 524 |
+
font-size: 0.7rem;
|
| 525 |
+
text-transform: uppercase;
|
| 526 |
+
letter-spacing: 0.05em;
|
| 527 |
+
background: var(--bg-elevated);
|
| 528 |
+
position: sticky;
|
| 529 |
+
top: 0;
|
| 530 |
+
}
|
| 531 |
+
.regions-table td { color: var(--text-muted); }
|
| 532 |
+
.regions-table tr:hover td { background: rgba(45, 212, 191, 0.03); }
|
| 533 |
+
.regions-table td:nth-child(2) { color: var(--text); font-weight: 500; }
|
| 534 |
+
|
| 535 |
+
/* ---- History ---- */
|
| 536 |
+
.history-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
| 537 |
+
.history-empty {
|
| 538 |
+
text-align: center;
|
| 539 |
+
color: var(--text-dim);
|
| 540 |
+
padding: 2rem 1rem;
|
| 541 |
+
font-size: 0.9rem;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.history-item {
|
| 545 |
+
display: flex;
|
| 546 |
+
align-items: center;
|
| 547 |
+
justify-content: space-between;
|
| 548 |
+
gap: 1rem;
|
| 549 |
+
padding: 0.85rem 1rem;
|
| 550 |
+
border: 1px solid var(--border);
|
| 551 |
+
border-radius: var(--radius-sm);
|
| 552 |
+
background: var(--bg-elevated);
|
| 553 |
+
transition: all var(--transition);
|
| 554 |
+
}
|
| 555 |
+
.history-item:hover { border-color: var(--border-hover); background: var(--bg-hover); }
|
| 556 |
+
|
| 557 |
+
.history-info { flex: 1; min-width: 0; }
|
| 558 |
+
.history-title {
|
| 559 |
+
font-weight: 600;
|
| 560 |
+
font-size: 0.9rem;
|
| 561 |
+
color: var(--text);
|
| 562 |
+
white-space: nowrap;
|
| 563 |
+
overflow: hidden;
|
| 564 |
+
text-overflow: ellipsis;
|
| 565 |
+
}
|
| 566 |
+
.history-meta {
|
| 567 |
+
font-size: 0.78rem;
|
| 568 |
+
color: var(--text-dim);
|
| 569 |
+
margin-top: 0.15rem;
|
| 570 |
+
}
|
| 571 |
+
.history-meta .tag {
|
| 572 |
+
display: inline-block;
|
| 573 |
+
background: var(--bg-card);
|
| 574 |
+
border: 1px solid var(--border);
|
| 575 |
+
padding: 0.1rem 0.45rem;
|
| 576 |
+
border-radius: var(--radius-xs);
|
| 577 |
+
font-size: 0.7rem;
|
| 578 |
+
font-family: var(--font-mono);
|
| 579 |
+
color: var(--text-muted);
|
| 580 |
+
margin-right: 0.3rem;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.history-actions {
|
| 584 |
+
display: flex;
|
| 585 |
+
align-items: center;
|
| 586 |
+
gap: 0.4rem;
|
| 587 |
+
flex-shrink: 0;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
/* ---- Modal ---- */
|
| 591 |
+
.modal-backdrop {
|
| 592 |
+
position: fixed;
|
| 593 |
+
inset: 0;
|
| 594 |
+
z-index: 1000;
|
| 595 |
+
background: rgba(0,0,0,0.6);
|
| 596 |
+
backdrop-filter: blur(4px);
|
| 597 |
+
display: flex;
|
| 598 |
+
align-items: center;
|
| 599 |
+
justify-content: center;
|
| 600 |
+
animation: fadeIn 0.15s ease;
|
| 601 |
+
}
|
| 602 |
+
@keyframes fadeIn {
|
| 603 |
+
from { opacity: 0; }
|
| 604 |
+
to { opacity: 1; }
|
| 605 |
+
}
|
| 606 |
+
.modal {
|
| 607 |
+
background: var(--bg-card);
|
| 608 |
+
border: 1px solid var(--border);
|
| 609 |
+
border-radius: var(--radius);
|
| 610 |
+
padding: 1.75rem;
|
| 611 |
+
max-width: 400px;
|
| 612 |
+
width: 90%;
|
| 613 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
| 614 |
+
}
|
| 615 |
+
.modal h3 {
|
| 616 |
+
font-size: 1.1rem;
|
| 617 |
+
font-weight: 600;
|
| 618 |
+
margin-bottom: 0.5rem;
|
| 619 |
+
}
|
| 620 |
+
.modal p {
|
| 621 |
+
color: var(--text-muted);
|
| 622 |
+
font-size: 0.9rem;
|
| 623 |
+
margin-bottom: 1.5rem;
|
| 624 |
+
line-height: 1.5;
|
| 625 |
+
}
|
| 626 |
+
.modal-actions {
|
| 627 |
+
display: flex;
|
| 628 |
+
justify-content: flex-end;
|
| 629 |
+
gap: 0.75rem;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
/* ---- Alerts ---- */
|
| 633 |
+
.alert {
|
| 634 |
+
padding: 0.7rem 1rem;
|
| 635 |
+
border-radius: var(--radius-sm);
|
| 636 |
+
font-size: 0.875rem;
|
| 637 |
+
margin-bottom: 1rem;
|
| 638 |
+
}
|
| 639 |
+
.alert-error {
|
| 640 |
+
background: var(--danger-glow);
|
| 641 |
+
border: 1px solid rgba(248, 113, 113, 0.3);
|
| 642 |
+
color: var(--danger);
|
| 643 |
+
}
|
| 644 |
+
.alert-success {
|
| 645 |
+
background: rgba(52, 211, 153, 0.12);
|
| 646 |
+
border: 1px solid rgba(52, 211, 153, 0.3);
|
| 647 |
+
color: var(--success);
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
/* ---- Loading ---- */
|
| 651 |
+
.loading {
|
| 652 |
+
display: inline-flex;
|
| 653 |
+
align-items: center;
|
| 654 |
+
gap: 0.5rem;
|
| 655 |
+
color: var(--text-muted);
|
| 656 |
+
font-size: 0.875rem;
|
| 657 |
+
}
|
| 658 |
+
.spinner {
|
| 659 |
+
width: 18px; height: 18px;
|
| 660 |
+
border: 2px solid var(--border);
|
| 661 |
+
border-top-color: var(--accent);
|
| 662 |
+
border-radius: 50%;
|
| 663 |
+
animation: spin 0.7s linear infinite;
|
| 664 |
+
}
|
| 665 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 666 |
+
|
| 667 |
+
/* ---- Footer ---- */
|
| 668 |
+
.app-footer {
|
| 669 |
+
margin-top: 3rem;
|
| 670 |
+
padding: 1.25rem 0;
|
| 671 |
+
border-top: 1px solid var(--border);
|
| 672 |
+
text-align: center;
|
| 673 |
+
}
|
| 674 |
+
.app-footer p {
|
| 675 |
+
font-size: 0.78rem;
|
| 676 |
+
color: var(--text-dim);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
/* ---- Utility ---- */
|
| 680 |
+
.hidden { display: none !important; }
|
| 681 |
+
.mt-1 { margin-top: 0.5rem; }
|
| 682 |
+
.mt-2 { margin-top: 1.25rem; }
|
| 683 |
+
.mb-2 { margin-bottom: 1rem; }
|
static/js/app.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE = '';
|
| 2 |
+
|
| 3 |
+
function getToken() { return localStorage.getItem('token'); }
|
| 4 |
+
function setToken(token) {
|
| 5 |
+
if (token) localStorage.setItem('token', token);
|
| 6 |
+
else localStorage.removeItem('token');
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function showView(id) {
|
| 10 |
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
| 11 |
+
const el = document.getElementById('view-' + id);
|
| 12 |
+
if (el) el.classList.add('active');
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function showError(id, msg) {
|
| 16 |
+
const el = document.getElementById(id);
|
| 17 |
+
if (!el) return;
|
| 18 |
+
el.textContent = msg;
|
| 19 |
+
el.classList.remove('hidden');
|
| 20 |
+
}
|
| 21 |
+
function hideError(id) {
|
| 22 |
+
const el = document.getElementById(id);
|
| 23 |
+
if (el) el.classList.add('hidden');
|
| 24 |
+
}
|
| 25 |
+
function showSuccess(id, msg) {
|
| 26 |
+
const el = document.getElementById(id);
|
| 27 |
+
if (!el) return;
|
| 28 |
+
el.textContent = msg;
|
| 29 |
+
el.classList.remove('hidden');
|
| 30 |
+
setTimeout(() => el.classList.add('hidden'), 4000);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function api(method, path, options = {}) {
|
| 34 |
+
const headers = { ...options.headers };
|
| 35 |
+
const token = getToken();
|
| 36 |
+
if (token) headers['Authorization'] = 'Bearer ' + token;
|
| 37 |
+
if (options.body && !(options.body instanceof FormData)) {
|
| 38 |
+
headers['Content-Type'] = 'application/json';
|
| 39 |
+
}
|
| 40 |
+
const res = await fetch(API_BASE + path, { method, headers, credentials: 'include', ...options });
|
| 41 |
+
const text = await res.text();
|
| 42 |
+
let data = null;
|
| 43 |
+
try { data = text ? JSON.parse(text) : null; } catch (_) {}
|
| 44 |
+
if (!res.ok) throw new Error(data?.detail || res.statusText || 'Request failed');
|
| 45 |
+
return data;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// ---- Auth ----
|
| 49 |
+
document.getElementById('form-login')?.addEventListener('submit', async (e) => {
|
| 50 |
+
e.preventDefault();
|
| 51 |
+
hideError('login-error');
|
| 52 |
+
const email = document.getElementById('login-email').value.trim();
|
| 53 |
+
const password = document.getElementById('login-password').value;
|
| 54 |
+
try {
|
| 55 |
+
const data = await api('POST', '/api/auth/login', { body: JSON.stringify({ email, password }) });
|
| 56 |
+
setToken(data.access_token);
|
| 57 |
+
document.getElementById('user-email').textContent = data.user.email;
|
| 58 |
+
showView('dashboard');
|
| 59 |
+
loadHistory();
|
| 60 |
+
} catch (err) { showError('login-error', err.message); }
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
document.getElementById('form-register')?.addEventListener('submit', async (e) => {
|
| 64 |
+
e.preventDefault();
|
| 65 |
+
hideError('register-error');
|
| 66 |
+
const full_name = document.getElementById('register-name').value.trim();
|
| 67 |
+
const email = document.getElementById('register-email').value.trim();
|
| 68 |
+
const password = document.getElementById('register-password').value;
|
| 69 |
+
try {
|
| 70 |
+
const data = await api('POST', '/api/auth/register', { body: JSON.stringify({ email, password, full_name }) });
|
| 71 |
+
setToken(data.access_token);
|
| 72 |
+
document.getElementById('user-email').textContent = data.user.email;
|
| 73 |
+
showView('dashboard');
|
| 74 |
+
loadHistory();
|
| 75 |
+
} catch (err) { showError('register-error', err.message); }
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
document.querySelectorAll('[data-view]').forEach((a) => {
|
| 79 |
+
a.addEventListener('click', (e) => {
|
| 80 |
+
e.preventDefault();
|
| 81 |
+
showView(a.getAttribute('data-view'));
|
| 82 |
+
hideError('login-error');
|
| 83 |
+
hideError('register-error');
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
document.getElementById('btn-logout')?.addEventListener('click', async () => {
|
| 88 |
+
try { await fetch(API_BASE + '/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
| 89 |
+
setToken(null);
|
| 90 |
+
showView('login');
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
async function init() {
|
| 94 |
+
const token = getToken();
|
| 95 |
+
if (!token) { showView('login'); return; }
|
| 96 |
+
try {
|
| 97 |
+
const user = await api('GET', '/api/me');
|
| 98 |
+
document.getElementById('user-email').textContent = user.email;
|
| 99 |
+
showView('dashboard');
|
| 100 |
+
loadHistory();
|
| 101 |
+
} catch (_) { setToken(null); showView('login'); }
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// ---- Upload zones with preview ----
|
| 105 |
+
function setupUploadZone(inputId, nameId, zoneId, previewId) {
|
| 106 |
+
const input = document.getElementById(inputId);
|
| 107 |
+
const nameEl = document.getElementById(nameId);
|
| 108 |
+
const zone = document.getElementById(zoneId);
|
| 109 |
+
const preview = document.getElementById(previewId);
|
| 110 |
+
if (!input || !nameEl || !zone) return;
|
| 111 |
+
|
| 112 |
+
function updatePreview() {
|
| 113 |
+
const file = input.files?.[0];
|
| 114 |
+
nameEl.textContent = file ? file.name : 'No file chosen';
|
| 115 |
+
if (file && preview) {
|
| 116 |
+
const reader = new FileReader();
|
| 117 |
+
reader.onload = () => {
|
| 118 |
+
preview.src = reader.result;
|
| 119 |
+
preview.classList.remove('hidden');
|
| 120 |
+
};
|
| 121 |
+
reader.readAsDataURL(file);
|
| 122 |
+
} else if (preview) {
|
| 123 |
+
preview.classList.add('hidden');
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
input.addEventListener('change', updatePreview);
|
| 128 |
+
zone.addEventListener('click', () => input.click());
|
| 129 |
+
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
|
| 130 |
+
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
| 131 |
+
zone.addEventListener('drop', (e) => {
|
| 132 |
+
e.preventDefault();
|
| 133 |
+
zone.classList.remove('dragover');
|
| 134 |
+
const file = e.dataTransfer?.files?.[0];
|
| 135 |
+
if (file && file.type.startsWith('image/')) {
|
| 136 |
+
input.files = e.dataTransfer.files;
|
| 137 |
+
updatePreview();
|
| 138 |
+
}
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
setupUploadZone('file-before', 'name-before', 'zone-before', 'preview-before');
|
| 143 |
+
setupUploadZone('file-after', 'name-after', 'zone-after', 'preview-after');
|
| 144 |
+
|
| 145 |
+
// ---- Run detection ----
|
| 146 |
+
document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
|
| 147 |
+
e.preventDefault();
|
| 148 |
+
hideError('dashboard-error');
|
| 149 |
+
const before = document.getElementById('file-before').files?.[0];
|
| 150 |
+
const after = document.getElementById('file-after').files?.[0];
|
| 151 |
+
if (!before || !after) {
|
| 152 |
+
showError('dashboard-error', 'Please select both before and after images.');
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const btn = document.getElementById('btn-run');
|
| 157 |
+
const loading = document.getElementById('run-loading');
|
| 158 |
+
btn.disabled = true;
|
| 159 |
+
loading.classList.remove('hidden');
|
| 160 |
+
|
| 161 |
+
const token = getToken();
|
| 162 |
+
const form = new FormData();
|
| 163 |
+
form.append('before', before);
|
| 164 |
+
form.append('after', after);
|
| 165 |
+
form.append('method', document.getElementById('detect-method').value);
|
| 166 |
+
form.append('title', document.getElementById('detect-title').value || 'Untitled run');
|
| 167 |
+
form.append('enable_registration', document.getElementById('detect-registration').checked);
|
| 168 |
+
form.append('enable_normalization', document.getElementById('detect-normalization').checked);
|
| 169 |
+
if (token) form.append('access_token', token);
|
| 170 |
+
|
| 171 |
+
try {
|
| 172 |
+
if (!token) {
|
| 173 |
+
showError('dashboard-error', 'Session expired. Please sign in again.');
|
| 174 |
+
setToken(null);
|
| 175 |
+
showView('login');
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
const data = await api('POST', '/api/detect', { body: form });
|
| 179 |
+
showResult(data);
|
| 180 |
+
showSuccess('dashboard-success', 'Detection complete!');
|
| 181 |
+
loadHistory();
|
| 182 |
+
} catch (err) {
|
| 183 |
+
showError('dashboard-error', err.message);
|
| 184 |
+
} finally {
|
| 185 |
+
btn.disabled = false;
|
| 186 |
+
loading.classList.add('hidden');
|
| 187 |
+
}
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
// ---- Show result ----
|
| 191 |
+
function readFileAsDataURL(file) {
|
| 192 |
+
return new Promise((resolve) => {
|
| 193 |
+
const reader = new FileReader();
|
| 194 |
+
reader.onload = () => resolve(reader.result);
|
| 195 |
+
reader.readAsDataURL(file);
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function showResult(data) {
|
| 200 |
+
const card = document.getElementById('result-card');
|
| 201 |
+
const statsEl = document.getElementById('result-stats');
|
| 202 |
+
const tbody = document.getElementById('regions-tbody');
|
| 203 |
+
|
| 204 |
+
statsEl.innerHTML = `
|
| 205 |
+
<div class="stat-box"><div class="value">${data.statistics.changePercentage.toFixed(2)}%</div><div class="label">Changed</div></div>
|
| 206 |
+
<div class="stat-box"><div class="value">${data.statistics.changedPixels.toLocaleString()}</div><div class="label">Changed px</div></div>
|
| 207 |
+
<div class="stat-box"><div class="value">${data.statistics.totalPixels.toLocaleString()}</div><div class="label">Total px</div></div>
|
| 208 |
+
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 209 |
+
`;
|
| 210 |
+
|
| 211 |
+
const beforeImg = document.getElementById('compare-before-img');
|
| 212 |
+
const afterImg = document.getElementById('compare-after-img');
|
| 213 |
+
const beforeFile = document.getElementById('file-before').files?.[0];
|
| 214 |
+
if (beforeFile) readFileAsDataURL(beforeFile).then((url) => { beforeImg.src = url; });
|
| 215 |
+
|
| 216 |
+
afterImg.src = data.overlayBase64Png
|
| 217 |
+
? 'data:image/png;base64,' + data.overlayBase64Png
|
| 218 |
+
: (data.overlayUrl || '');
|
| 219 |
+
|
| 220 |
+
resetCompareSlider();
|
| 221 |
+
|
| 222 |
+
tbody.innerHTML = '';
|
| 223 |
+
(data.regions || []).slice(0, 50).forEach((r) => {
|
| 224 |
+
const tr = document.createElement('tr');
|
| 225 |
+
tr.innerHTML = `
|
| 226 |
+
<td>${r.id}</td>
|
| 227 |
+
<td>${r.objectType}</td>
|
| 228 |
+
<td>${(r.confidence * 100).toFixed(1)}%</td>
|
| 229 |
+
<td>${r.area.toLocaleString()}</td>
|
| 230 |
+
<td>(${r.center.x}, ${r.center.y})</td>
|
| 231 |
+
`;
|
| 232 |
+
tbody.appendChild(tr);
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
card.classList.remove('hidden');
|
| 236 |
+
card.scrollIntoView({ behavior: 'smooth' });
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// ---- Compare slider ----
|
| 240 |
+
function initCompareSlider() {
|
| 241 |
+
const slider = document.getElementById('compare-slider');
|
| 242 |
+
if (!slider) return;
|
| 243 |
+
let isDragging = false;
|
| 244 |
+
|
| 245 |
+
function updatePosition(clientX) {
|
| 246 |
+
const rect = slider.getBoundingClientRect();
|
| 247 |
+
let pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
| 248 |
+
document.getElementById('compare-after-clip').style.clipPath = `inset(0 0 0 ${pct}%)`;
|
| 249 |
+
document.getElementById('compare-handle').style.left = pct + '%';
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
slider.addEventListener('mousedown', (e) => { e.preventDefault(); isDragging = true; updatePosition(e.clientX); });
|
| 253 |
+
document.addEventListener('mousemove', (e) => { if (isDragging) updatePosition(e.clientX); });
|
| 254 |
+
document.addEventListener('mouseup', () => { isDragging = false; });
|
| 255 |
+
|
| 256 |
+
slider.addEventListener('touchstart', (e) => { isDragging = true; updatePosition(e.touches[0].clientX); }, { passive: true });
|
| 257 |
+
document.addEventListener('touchmove', (e) => { if (isDragging) updatePosition(e.touches[0].clientX); }, { passive: true });
|
| 258 |
+
document.addEventListener('touchend', () => { isDragging = false; });
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function resetCompareSlider() {
|
| 262 |
+
const ac = document.getElementById('compare-after-clip');
|
| 263 |
+
const h = document.getElementById('compare-handle');
|
| 264 |
+
if (ac) ac.style.clipPath = 'inset(0 0 0 50%)';
|
| 265 |
+
if (h) h.style.left = '50%';
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
initCompareSlider();
|
| 269 |
+
|
| 270 |
+
// ---- History with delete ----
|
| 271 |
+
async function loadHistory() {
|
| 272 |
+
const list = document.getElementById('history-list');
|
| 273 |
+
if (!list) return;
|
| 274 |
+
try {
|
| 275 |
+
const items = await api('GET', '/api/history');
|
| 276 |
+
if (!items || items.length === 0) {
|
| 277 |
+
list.innerHTML = '<div class="history-empty">No detection runs yet. Upload images above to get started.</div>';
|
| 278 |
+
return;
|
| 279 |
+
}
|
| 280 |
+
list.innerHTML = items.map((r) => `
|
| 281 |
+
<div class="history-item" data-id="${r.id}">
|
| 282 |
+
<div class="history-info">
|
| 283 |
+
<div class="history-title">${escapeHtml(r.title)}</div>
|
| 284 |
+
<div class="history-meta">
|
| 285 |
+
<span class="tag">${r.method}</span>
|
| 286 |
+
${r.changePercentage.toFixed(2)}% changed · ${r.regionsCount} regions · ${formatDate(r.createdAt)}
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="history-actions">
|
| 290 |
+
${r.overlayUrl ? `<a href="${r.overlayUrl}" target="_blank" class="btn btn-secondary btn-sm">View</a>` : ''}
|
| 291 |
+
<button class="btn-icon" title="Delete this run" onclick="confirmDelete(${r.id})">
|
| 292 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
| 293 |
+
</button>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
`).join('');
|
| 297 |
+
} catch (_) {
|
| 298 |
+
list.innerHTML = '<div class="history-empty">Could not load history.</div>';
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function formatDate(iso) {
|
| 303 |
+
const d = new Date(iso);
|
| 304 |
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
| 305 |
+
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
// ---- Delete modal ----
|
| 309 |
+
let pendingDeleteId = null;
|
| 310 |
+
|
| 311 |
+
function confirmDelete(id) {
|
| 312 |
+
pendingDeleteId = id;
|
| 313 |
+
document.getElementById('modal-delete').classList.remove('hidden');
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
document.getElementById('modal-cancel')?.addEventListener('click', () => {
|
| 317 |
+
document.getElementById('modal-delete').classList.add('hidden');
|
| 318 |
+
pendingDeleteId = null;
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
+
document.getElementById('modal-confirm')?.addEventListener('click', async () => {
|
| 322 |
+
if (!pendingDeleteId) return;
|
| 323 |
+
const id = pendingDeleteId;
|
| 324 |
+
document.getElementById('modal-delete').classList.add('hidden');
|
| 325 |
+
pendingDeleteId = null;
|
| 326 |
+
try {
|
| 327 |
+
await api('DELETE', `/api/history/${id}`);
|
| 328 |
+
// Animate removal
|
| 329 |
+
const item = document.querySelector(`.history-item[data-id="${id}"]`);
|
| 330 |
+
if (item) {
|
| 331 |
+
item.style.transition = 'all 0.3s ease';
|
| 332 |
+
item.style.opacity = '0';
|
| 333 |
+
item.style.transform = 'translateX(20px)';
|
| 334 |
+
setTimeout(() => { item.remove(); loadHistory(); }, 300);
|
| 335 |
+
} else {
|
| 336 |
+
loadHistory();
|
| 337 |
+
}
|
| 338 |
+
showSuccess('dashboard-success', 'Run deleted.');
|
| 339 |
+
} catch (err) {
|
| 340 |
+
showError('dashboard-error', err.message);
|
| 341 |
+
}
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
// Close modal on backdrop click
|
| 345 |
+
document.getElementById('modal-delete')?.addEventListener('click', (e) => {
|
| 346 |
+
if (e.target === e.currentTarget) {
|
| 347 |
+
e.currentTarget.classList.add('hidden');
|
| 348 |
+
pendingDeleteId = null;
|
| 349 |
+
}
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
function escapeHtml(s) {
|
| 353 |
+
const div = document.createElement('div');
|
| 354 |
+
div.textContent = s;
|
| 355 |
+
return div.innerHTML;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
init();
|
templates/index.html
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Satellite Change Detection</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="app">
|
| 11 |
+
<!-- Login view -->
|
| 12 |
+
<section id="view-login" class="view">
|
| 13 |
+
<div class="auth-container">
|
| 14 |
+
<div class="auth-logo">
|
| 15 |
+
<div class="auth-logo-icon">
|
| 16 |
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
| 17 |
+
</div>
|
| 18 |
+
<span>SatDetect</span>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="card">
|
| 21 |
+
<h2>Welcome back</h2>
|
| 22 |
+
<p class="sub">Sign in to your account to continue.</p>
|
| 23 |
+
<div id="login-error" class="alert alert-error hidden"></div>
|
| 24 |
+
<form id="form-login">
|
| 25 |
+
<div class="form-group">
|
| 26 |
+
<label for="login-email">Email</label>
|
| 27 |
+
<input type="email" id="login-email" required placeholder="you@example.com" />
|
| 28 |
+
</div>
|
| 29 |
+
<div class="form-group">
|
| 30 |
+
<label for="login-password">Password</label>
|
| 31 |
+
<input type="password" id="login-password" required placeholder="Enter your password" />
|
| 32 |
+
</div>
|
| 33 |
+
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
|
| 34 |
+
</form>
|
| 35 |
+
<p class="toggle-auth">Don't have an account? <a href="#" data-view="register">Create one</a></p>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<!-- Register view -->
|
| 41 |
+
<section id="view-register" class="view">
|
| 42 |
+
<div class="auth-container">
|
| 43 |
+
<div class="auth-logo">
|
| 44 |
+
<div class="auth-logo-icon">
|
| 45 |
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
| 46 |
+
</div>
|
| 47 |
+
<span>SatDetect</span>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="card">
|
| 50 |
+
<h2>Create account</h2>
|
| 51 |
+
<p class="sub">Register to save and manage your detection runs.</p>
|
| 52 |
+
<div id="register-error" class="alert alert-error hidden"></div>
|
| 53 |
+
<form id="form-register">
|
| 54 |
+
<div class="form-group">
|
| 55 |
+
<label for="register-name">Full name</label>
|
| 56 |
+
<input type="text" id="register-name" placeholder="Your name" />
|
| 57 |
+
</div>
|
| 58 |
+
<div class="form-group">
|
| 59 |
+
<label for="register-email">Email</label>
|
| 60 |
+
<input type="email" id="register-email" required placeholder="you@example.com" />
|
| 61 |
+
</div>
|
| 62 |
+
<div class="form-group">
|
| 63 |
+
<label for="register-password">Password</label>
|
| 64 |
+
<input type="password" id="register-password" required placeholder="Min. 6 characters" minlength="6" />
|
| 65 |
+
</div>
|
| 66 |
+
<button type="submit" class="btn btn-primary btn-block">Create account</button>
|
| 67 |
+
</form>
|
| 68 |
+
<p class="toggle-auth">Already have an account? <a href="#" data-view="login">Sign in</a></p>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</section>
|
| 72 |
+
|
| 73 |
+
<!-- Dashboard view -->
|
| 74 |
+
<section id="view-dashboard" class="view">
|
| 75 |
+
<nav class="nav">
|
| 76 |
+
<div class="nav-brand">
|
| 77 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
| 78 |
+
<span>SatDetect</span>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="nav-user">
|
| 81 |
+
<span class="user-badge" id="user-email"></span>
|
| 82 |
+
<button type="button" class="btn btn-ghost btn-sm" id="btn-logout">
|
| 83 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
| 84 |
+
Log out
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
+
</nav>
|
| 88 |
+
|
| 89 |
+
<header class="page-header">
|
| 90 |
+
<h1>Change Detection</h1>
|
| 91 |
+
<p>Upload two satellite images (before & after) to detect and classify ground-level changes.</p>
|
| 92 |
+
</header>
|
| 93 |
+
|
| 94 |
+
<div id="dashboard-error" class="alert alert-error hidden"></div>
|
| 95 |
+
<div id="dashboard-success" class="alert alert-success hidden"></div>
|
| 96 |
+
|
| 97 |
+
<!-- New run card -->
|
| 98 |
+
<div class="card">
|
| 99 |
+
<div class="card-header">
|
| 100 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
| 101 |
+
<h3>New Detection Run</h3>
|
| 102 |
+
</div>
|
| 103 |
+
<form id="form-detect">
|
| 104 |
+
<div class="upload-grid">
|
| 105 |
+
<div class="upload-zone" id="zone-before">
|
| 106 |
+
<input type="file" id="file-before" accept="image/png,image/jpeg,image/jpg" />
|
| 107 |
+
<div class="upload-icon">
|
| 108 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
| 109 |
+
</div>
|
| 110 |
+
<label for="file-before">Before image <span class="dim">(older)</span></label>
|
| 111 |
+
<div class="filename" id="name-before">No file chosen</div>
|
| 112 |
+
<img class="upload-preview hidden" id="preview-before" alt="" />
|
| 113 |
+
</div>
|
| 114 |
+
<div class="upload-zone" id="zone-after">
|
| 115 |
+
<input type="file" id="file-after" accept="image/png,image/jpeg,image/jpg" />
|
| 116 |
+
<div class="upload-icon">
|
| 117 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
| 118 |
+
</div>
|
| 119 |
+
<label for="file-after">After image <span class="dim">(current)</span></label>
|
| 120 |
+
<div class="filename" id="name-after">No file chosen</div>
|
| 121 |
+
<img class="upload-preview hidden" id="preview-after" alt="" />
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="options-row">
|
| 125 |
+
<div class="form-group">
|
| 126 |
+
<label for="detect-title">Title</label>
|
| 127 |
+
<input type="text" id="detect-title" value="Untitled run" placeholder="Run title" />
|
| 128 |
+
</div>
|
| 129 |
+
<div class="form-group">
|
| 130 |
+
<label for="detect-method">Method</label>
|
| 131 |
+
<select id="detect-method">
|
| 132 |
+
<option value="AI-Based Deep Learning">AI-Based Deep Learning</option>
|
| 133 |
+
<option value="Image Difference">Image Difference</option>
|
| 134 |
+
<option value="Feature-Based">Feature-Based</option>
|
| 135 |
+
<option value="Hybrid Approach">Hybrid Approach</option>
|
| 136 |
+
</select>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="form-group checkbox-group">
|
| 139 |
+
<label><input type="checkbox" id="detect-registration" checked /> Image Registration</label>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="form-group checkbox-group">
|
| 142 |
+
<label><input type="checkbox" id="detect-normalization" checked /> Radiometric Normalization</label>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="run-btn-wrap">
|
| 146 |
+
<button type="submit" class="btn btn-primary btn-lg" id="btn-run">
|
| 147 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
| 148 |
+
Run Detection
|
| 149 |
+
</button>
|
| 150 |
+
<span class="loading hidden" id="run-loading"><span class="spinner"></span> Analyzing images...</span>
|
| 151 |
+
</div>
|
| 152 |
+
</form>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<!-- Result card (shown after a run) -->
|
| 156 |
+
<div class="card hidden" id="result-card">
|
| 157 |
+
<div class="card-header">
|
| 158 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
| 159 |
+
<h3>Detection Result</h3>
|
| 160 |
+
</div>
|
| 161 |
+
<div class="result-stats" id="result-stats"></div>
|
| 162 |
+
|
| 163 |
+
<!-- Before / After comparison slider -->
|
| 164 |
+
<div class="compare-slider" id="compare-slider">
|
| 165 |
+
<div class="compare-before">
|
| 166 |
+
<img id="compare-before-img" alt="Before" draggable="false" />
|
| 167 |
+
<span class="compare-label compare-label-left">Before</span>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="compare-after" id="compare-after-clip">
|
| 170 |
+
<img id="compare-after-img" alt="Changes detected" draggable="false" />
|
| 171 |
+
<span class="compare-label compare-label-right">Changes</span>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="compare-handle" id="compare-handle">
|
| 174 |
+
<div class="compare-handle-line"></div>
|
| 175 |
+
<div class="compare-handle-knob">
|
| 176 |
+
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 10l4-5v10z" fill="currentColor"/><path d="M14 10l-4-5v10z" fill="currentColor"/></svg>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="compare-handle-line"></div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div class="regions-table-wrap">
|
| 183 |
+
<table class="regions-table" id="regions-table">
|
| 184 |
+
<thead>
|
| 185 |
+
<tr>
|
| 186 |
+
<th>#</th>
|
| 187 |
+
<th>Ground Change Type</th>
|
| 188 |
+
<th>Confidence</th>
|
| 189 |
+
<th>Area (px)</th>
|
| 190 |
+
<th>Center</th>
|
| 191 |
+
</tr>
|
| 192 |
+
</thead>
|
| 193 |
+
<tbody id="regions-tbody"></tbody>
|
| 194 |
+
</table>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<!-- History -->
|
| 199 |
+
<div class="card mt-2">
|
| 200 |
+
<div class="card-header">
|
| 201 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 202 |
+
<h3>History</h3>
|
| 203 |
+
</div>
|
| 204 |
+
<div id="history-list" class="history-list"></div>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<footer class="app-footer">
|
| 208 |
+
<p>SatDetect — Satellite Change Detection · Powered by multi-signal fusion analysis</p>
|
| 209 |
+
</footer>
|
| 210 |
+
</section>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<!-- Delete confirm modal -->
|
| 214 |
+
<div class="modal-backdrop hidden" id="modal-delete">
|
| 215 |
+
<div class="modal">
|
| 216 |
+
<h3>Delete detection run?</h3>
|
| 217 |
+
<p>This will permanently remove this run and its overlay image. This cannot be undone.</p>
|
| 218 |
+
<div class="modal-actions">
|
| 219 |
+
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
| 220 |
+
<button class="btn btn-danger" id="modal-confirm">Delete</button>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
<script src="/static/js/app.js?v=5"></script>
|
| 226 |
+
</body>
|
| 227 |
+
</html>
|