coderuday21 Cursor commited on
Commit
401aaf2
·
0 Parent(s):

Initial commit: SatDetect satellite change detection web app

Browse files
.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 &middot; ${r.regionsCount} regions &middot; ${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 &amp; 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 &mdash; Satellite Change Detection &middot; 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>