Spaces:
Sleeping
Sleeping
Commit ·
0bf1136
1
Parent(s): f29f921
Full codebase audit: fix critical perf bug, security, error handling, dead code
Browse files- app/auth.py +7 -1
- app/detection_engine.py +15 -26
- app/main.py +38 -27
- app/models.py +8 -4
- app/notifier.py +4 -4
- requirements.txt +0 -3
- static/js/app.js +10 -5
- templates/index.html +1 -1
app/auth.py
CHANGED
|
@@ -14,7 +14,13 @@ from .models import User
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
ALGORITHM = "HS256"
|
| 19 |
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 20 |
COOKIE_NAME = "satellite_token"
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
_FALLBACK_KEY = "dev-fallback-key-change-in-production"
|
| 18 |
+
SECRET_KEY = os.environ.get("SECRET_KEY", _FALLBACK_KEY)
|
| 19 |
+
if SECRET_KEY == _FALLBACK_KEY:
|
| 20 |
+
logger.warning(
|
| 21 |
+
"SECRET_KEY env var not set — using insecure fallback. "
|
| 22 |
+
"Set SECRET_KEY to a random string in production!"
|
| 23 |
+
)
|
| 24 |
ALGORITHM = "HS256"
|
| 25 |
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 26 |
COOKIE_NAME = "satellite_token"
|
app/detection_engine.py
CHANGED
|
@@ -3,7 +3,6 @@ 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
|
|
@@ -409,9 +408,9 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 409 |
5. Fill holes inside regions
|
| 410 |
6. Erode-then-dilate to break thin noise bridges between separate changes
|
| 411 |
"""
|
|
|
|
| 412 |
h, w = mask.shape[:2]
|
| 413 |
|
| 414 |
-
# 1. Remove false positives along image border (common with registration)
|
| 415 |
if border_margin > 0:
|
| 416 |
mask[:border_margin, :] = 0
|
| 417 |
mask[-border_margin:, :] = 0
|
|
@@ -456,18 +455,18 @@ def _severity_from_region(region, total_pixels):
|
|
| 456 |
"""
|
| 457 |
Classify change severity from area and confidence.
|
| 458 |
Green = minor, Yellow = moderate, Red = major.
|
| 459 |
-
|
| 460 |
"""
|
| 461 |
area = region.get("area", 0)
|
| 462 |
confidence = region.get("confidence", 0.0)
|
| 463 |
if total_pixels <= 0:
|
| 464 |
return "minor"
|
| 465 |
area_ratio = area / total_pixels
|
| 466 |
-
#
|
| 467 |
-
score = area_ratio *
|
| 468 |
-
if score <
|
| 469 |
return "minor"
|
| 470 |
-
if score <
|
| 471 |
return "moderate"
|
| 472 |
return "major"
|
| 473 |
|
|
@@ -506,7 +505,6 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
|
|
| 506 |
total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
|
| 507 |
|
| 508 |
if regions:
|
| 509 |
-
# Scale line thickness to image size so boxes are visible at any resolution
|
| 510 |
diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
|
| 511 |
line_thickness = max(2, int(diag / 400))
|
| 512 |
|
|
@@ -515,15 +513,17 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
|
|
| 515 |
severity = r.get("severity") or _severity_from_region(r, total_px)
|
| 516 |
color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
|
| 517 |
|
| 518 |
-
# Semi-transparent
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
|
| 523 |
-
# Color-coded bounding box
|
| 524 |
cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
|
| 525 |
|
| 526 |
-
# Numbered label
|
| 527 |
rid = r.get("id", 0)
|
| 528 |
label = str(rid)
|
| 529 |
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
@@ -629,17 +629,6 @@ def _is_transient_object(area, w, h, features):
|
|
| 629 |
return False
|
| 630 |
|
| 631 |
|
| 632 |
-
# Ground-level change categories only
|
| 633 |
-
GROUND_CHANGE_TYPES = [
|
| 634 |
-
"New Construction/Building",
|
| 635 |
-
"Demolition/Clearing",
|
| 636 |
-
"Vegetation Change",
|
| 637 |
-
"Water Body Change",
|
| 638 |
-
"Road/Pavement Change",
|
| 639 |
-
"Bare Land/Soil Change",
|
| 640 |
-
]
|
| 641 |
-
|
| 642 |
-
|
| 643 |
def classify_object_type(image_region, bbox):
|
| 644 |
"""
|
| 645 |
Classify GROUND-LEVEL structural changes only.
|
|
@@ -800,7 +789,7 @@ def classify_object_type(image_region, bbox):
|
|
| 800 |
return best, min(conf, 1.0)
|
| 801 |
|
| 802 |
|
| 803 |
-
def classify_with_ensemble(image_region, bbox
|
| 804 |
"""Ensemble: classify full region + sub-regions, vote with confidence weighting."""
|
| 805 |
x, y, w, h = bbox
|
| 806 |
sub_boxes = [(x, y, w, h)] # full region
|
|
|
|
| 3 |
High-accuracy detection with multi-channel analysis, SSIM, texture features,
|
| 4 |
adaptive thresholding, and improved object classification.
|
| 5 |
"""
|
|
|
|
| 6 |
import numpy as np
|
| 7 |
import cv2
|
| 8 |
from PIL import Image
|
|
|
|
| 408 |
5. Fill holes inside regions
|
| 409 |
6. Erode-then-dilate to break thin noise bridges between separate changes
|
| 410 |
"""
|
| 411 |
+
mask = mask.copy()
|
| 412 |
h, w = mask.shape[:2]
|
| 413 |
|
|
|
|
| 414 |
if border_margin > 0:
|
| 415 |
mask[:border_margin, :] = 0
|
| 416 |
mask[-border_margin:, :] = 0
|
|
|
|
| 455 |
"""
|
| 456 |
Classify change severity from area and confidence.
|
| 457 |
Green = minor, Yellow = moderate, Red = major.
|
| 458 |
+
Area is the primary signal; confidence acts as a small bonus.
|
| 459 |
"""
|
| 460 |
area = region.get("area", 0)
|
| 461 |
confidence = region.get("confidence", 0.0)
|
| 462 |
if total_pixels <= 0:
|
| 463 |
return "minor"
|
| 464 |
area_ratio = area / total_pixels
|
| 465 |
+
# Area-dominant score: area ratio (0-1) mapped to 0-10, confidence adds 0-0.3
|
| 466 |
+
score = area_ratio * 1000 + confidence * 0.3
|
| 467 |
+
if score < 1.0:
|
| 468 |
return "minor"
|
| 469 |
+
if score < 4.0:
|
| 470 |
return "moderate"
|
| 471 |
return "major"
|
| 472 |
|
|
|
|
| 505 |
total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
|
| 506 |
|
| 507 |
if regions:
|
|
|
|
| 508 |
diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
|
| 509 |
line_thickness = max(2, int(diag / 400))
|
| 510 |
|
|
|
|
| 513 |
severity = r.get("severity") or _severity_from_region(r, total_px)
|
| 514 |
color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
|
| 515 |
|
| 516 |
+
# Semi-transparent fill using only the ROI (avoids full-image copy)
|
| 517 |
+
x1c = max(0, x)
|
| 518 |
+
y1c = max(0, y)
|
| 519 |
+
x2c = min(overlay_uint8.shape[1], x + w)
|
| 520 |
+
y2c = min(overlay_uint8.shape[0], y + h)
|
| 521 |
+
roi = overlay_uint8[y1c:y2c, x1c:x2c]
|
| 522 |
+
fill = np.full_like(roi, color, dtype=np.uint8)
|
| 523 |
+
cv2.addWeighted(fill, 0.12, roi, 0.88, 0, roi)
|
| 524 |
|
|
|
|
| 525 |
cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
|
| 526 |
|
|
|
|
| 527 |
rid = r.get("id", 0)
|
| 528 |
label = str(rid)
|
| 529 |
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
|
|
| 629 |
return False
|
| 630 |
|
| 631 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
def classify_object_type(image_region, bbox):
|
| 633 |
"""
|
| 634 |
Classify GROUND-LEVEL structural changes only.
|
|
|
|
| 789 |
return best, min(conf, 1.0)
|
| 790 |
|
| 791 |
|
| 792 |
+
def classify_with_ensemble(image_region, bbox):
|
| 793 |
"""Ensemble: classify full region + sub-regions, vote with confidence weighting."""
|
| 794 |
x, y, w, h = bbox
|
| 795 |
sub_boxes = [(x, y, w, h)] # full region
|
app/main.py
CHANGED
|
@@ -25,9 +25,11 @@ from .auth import (
|
|
| 25 |
)
|
| 26 |
from .database import Base, engine, get_db, DATA_DIR
|
| 27 |
from .models import User, DetectionRun
|
| 28 |
-
# detection_engine (cv2, sklearn) imported lazily in /api/detect to speed up startup on HF Spaces
|
| 29 |
from .notifier import send_notification
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
# Create tables and run migrations without crashing the app (HF Spaces can restart if startup fails)
|
| 32 |
try:
|
| 33 |
Base.metadata.create_all(bind=engine, checkfirst=True)
|
|
@@ -124,8 +126,8 @@ def register(data: UserCreate, db: Session = Depends(get_db)):
|
|
| 124 |
except HTTPException:
|
| 125 |
raise
|
| 126 |
except Exception as e:
|
| 127 |
-
|
| 128 |
-
raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}
|
| 129 |
|
| 130 |
|
| 131 |
@app.post("/api/auth/login")
|
|
@@ -139,8 +141,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
|
|
| 139 |
except HTTPException:
|
| 140 |
raise
|
| 141 |
except Exception as e:
|
| 142 |
-
|
| 143 |
-
raise HTTPException(status_code=500, detail=f"Login failed: {type(e).__name__}
|
| 144 |
|
| 145 |
|
| 146 |
@app.post("/api/auth/logout")
|
|
@@ -234,25 +236,34 @@ async def detect(
|
|
| 234 |
overlay_path.parent.mkdir(parents=True, exist_ok=True)
|
| 235 |
except Exception:
|
| 236 |
pass
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
relative_overlay = f"overlays/{overlay_filename}"
|
| 239 |
|
| 240 |
# Save full-resolution before image (used by the before/after slider from history)
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
regions_serializable = [
|
| 258 |
{
|
|
@@ -294,11 +305,8 @@ async def detect(
|
|
| 294 |
db.add(run)
|
| 295 |
db.commit()
|
| 296 |
db.refresh(run)
|
| 297 |
-
#
|
| 298 |
-
|
| 299 |
-
Image.fromarray(result_image).save(buf, format="PNG")
|
| 300 |
-
buf.seek(0)
|
| 301 |
-
overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
|
| 302 |
|
| 303 |
# Send email notification if requested
|
| 304 |
notification_sent = False
|
|
@@ -393,7 +401,10 @@ def get_run(
|
|
| 393 |
run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
|
| 394 |
if not run:
|
| 395 |
raise HTTPException(status_code=404, detail="Run not found")
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
return {
|
| 398 |
"id": run.id,
|
| 399 |
"title": run.title,
|
|
|
|
| 25 |
)
|
| 26 |
from .database import Base, engine, get_db, DATA_DIR
|
| 27 |
from .models import User, DetectionRun
|
|
|
|
| 28 |
from .notifier import send_notification
|
| 29 |
|
| 30 |
+
import logging
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
# Create tables and run migrations without crashing the app (HF Spaces can restart if startup fails)
|
| 34 |
try:
|
| 35 |
Base.metadata.create_all(bind=engine, checkfirst=True)
|
|
|
|
| 126 |
except HTTPException:
|
| 127 |
raise
|
| 128 |
except Exception as e:
|
| 129 |
+
logger.exception("Registration failed")
|
| 130 |
+
raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}")
|
| 131 |
|
| 132 |
|
| 133 |
@app.post("/api/auth/login")
|
|
|
|
| 141 |
except HTTPException:
|
| 142 |
raise
|
| 143 |
except Exception as e:
|
| 144 |
+
logger.exception("Login failed")
|
| 145 |
+
raise HTTPException(status_code=500, detail=f"Login failed: {type(e).__name__}")
|
| 146 |
|
| 147 |
|
| 148 |
@app.post("/api/auth/logout")
|
|
|
|
| 236 |
overlay_path.parent.mkdir(parents=True, exist_ok=True)
|
| 237 |
except Exception:
|
| 238 |
pass
|
| 239 |
+
try:
|
| 240 |
+
Image.fromarray(result_image).save(overlay_path)
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
logger.error("Failed to save overlay: %s", exc)
|
| 243 |
+
raise HTTPException(status_code=500, detail="Could not save result image")
|
| 244 |
relative_overlay = f"overlays/{overlay_filename}"
|
| 245 |
|
| 246 |
# Save full-resolution before image (used by the before/after slider from history)
|
| 247 |
+
relative_before_full = ""
|
| 248 |
+
relative_before_thumb = ""
|
| 249 |
+
relative_after_thumb = ""
|
| 250 |
+
try:
|
| 251 |
+
before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
|
| 252 |
+
before_pil.save(before_full_file)
|
| 253 |
+
relative_before_full = f"overlays/{base_name}_before.png"
|
| 254 |
+
|
| 255 |
+
before_thumb_file = OVERLAYS_DIR / f"{base_name}_before_thumb.png"
|
| 256 |
+
after_thumb_file = OVERLAYS_DIR / f"{base_name}_after_thumb.png"
|
| 257 |
+
before_thumb_pil = before_pil.copy()
|
| 258 |
+
before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 259 |
+
before_thumb_pil.save(before_thumb_file)
|
| 260 |
+
after_thumb_pil = after_pil.copy()
|
| 261 |
+
after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 262 |
+
after_thumb_pil.save(after_thumb_file)
|
| 263 |
+
relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
|
| 264 |
+
relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
logger.warning("Failed to save thumbnails: %s", exc)
|
| 267 |
|
| 268 |
regions_serializable = [
|
| 269 |
{
|
|
|
|
| 305 |
db.add(run)
|
| 306 |
db.commit()
|
| 307 |
db.refresh(run)
|
| 308 |
+
# Read already-saved overlay for base64 (avoids re-encoding the numpy array)
|
| 309 |
+
overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
# Send email notification if requested
|
| 312 |
notification_sent = False
|
|
|
|
| 401 |
run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
|
| 402 |
if not run:
|
| 403 |
raise HTTPException(status_code=404, detail="Run not found")
|
| 404 |
+
try:
|
| 405 |
+
regions = json.loads(run.regions_json) if run.regions_json else []
|
| 406 |
+
except (json.JSONDecodeError, TypeError):
|
| 407 |
+
regions = []
|
| 408 |
return {
|
| 409 |
"id": run.id,
|
| 410 |
"title": run.title,
|
app/models.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 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 |
|
|
@@ -12,7 +16,7 @@ class User(Base):
|
|
| 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=
|
| 16 |
|
| 17 |
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
|
| 18 |
|
|
@@ -34,7 +38,7 @@ class DetectionRun(Base):
|
|
| 34 |
after_thumb_path = Column(String(512), default="")
|
| 35 |
zone = Column(String(128), default="")
|
| 36 |
village = Column(String(128), default="")
|
| 37 |
-
regions_json = Column(Text, default="[]")
|
| 38 |
-
created_at = Column(DateTime, default=
|
| 39 |
|
| 40 |
user = relationship("User", back_populates="detections")
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 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 |
+
def _utcnow():
|
| 9 |
+
return datetime.now(timezone.utc)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
class User(Base):
|
| 13 |
__tablename__ = "users"
|
| 14 |
|
|
|
|
| 16 |
email = Column(String(255), unique=True, index=True, nullable=False)
|
| 17 |
hashed_password = Column(String(255), nullable=False)
|
| 18 |
full_name = Column(String(255), default="")
|
| 19 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 20 |
|
| 21 |
detections = relationship("DetectionRun", back_populates="user", order_by="desc(DetectionRun.created_at)")
|
| 22 |
|
|
|
|
| 38 |
after_thumb_path = Column(String(512), default="")
|
| 39 |
zone = Column(String(128), default="")
|
| 40 |
village = Column(String(128), default="")
|
| 41 |
+
regions_json = Column(Text, default="[]")
|
| 42 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 43 |
|
| 44 |
user = relationship("User", back_populates="detections")
|
app/notifier.py
CHANGED
|
@@ -5,6 +5,7 @@ Credentials are read from environment variables — never hardcoded.
|
|
| 5 |
"""
|
| 6 |
import logging
|
| 7 |
import os
|
|
|
|
| 8 |
import smtplib
|
| 9 |
import ssl
|
| 10 |
from datetime import datetime, timezone
|
|
@@ -16,7 +17,7 @@ logger = logging.getLogger(__name__)
|
|
| 16 |
|
| 17 |
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
|
| 18 |
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
| 19 |
-
SMTP_USER = os.environ.get("SMTP_USER", "
|
| 20 |
SMTP_PASS = os.environ.get("SMTP_PASS", "")
|
| 21 |
|
| 22 |
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
|
|
@@ -86,7 +87,6 @@ def build_email_body(
|
|
| 86 |
if regions:
|
| 87 |
html = html.replace("{{#regions}}", "").replace("{{/regions}}", "")
|
| 88 |
else:
|
| 89 |
-
import re
|
| 90 |
html = re.sub(r"\{\{#regions\}\}.*?\{\{/regions\}\}", "", html, flags=re.DOTALL)
|
| 91 |
|
| 92 |
return html
|
|
@@ -107,8 +107,8 @@ def send_notification(
|
|
| 107 |
Send detection report email to the recipient.
|
| 108 |
Returns True on success, False on failure (never raises).
|
| 109 |
"""
|
| 110 |
-
if not SMTP_PASS:
|
| 111 |
-
logger.warning("SMTP_PASS not set — skipping email notification")
|
| 112 |
return False
|
| 113 |
|
| 114 |
html_body = build_email_body(
|
|
|
|
| 5 |
"""
|
| 6 |
import logging
|
| 7 |
import os
|
| 8 |
+
import re
|
| 9 |
import smtplib
|
| 10 |
import ssl
|
| 11 |
from datetime import datetime, timezone
|
|
|
|
| 17 |
|
| 18 |
SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
|
| 19 |
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
|
| 20 |
+
SMTP_USER = os.environ.get("SMTP_USER", "")
|
| 21 |
SMTP_PASS = os.environ.get("SMTP_PASS", "")
|
| 22 |
|
| 23 |
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
|
|
|
|
| 87 |
if regions:
|
| 88 |
html = html.replace("{{#regions}}", "").replace("{{/regions}}", "")
|
| 89 |
else:
|
|
|
|
| 90 |
html = re.sub(r"\{\{#regions\}\}.*?\{\{/regions\}\}", "", html, flags=re.DOTALL)
|
| 91 |
|
| 92 |
return html
|
|
|
|
| 107 |
Send detection report email to the recipient.
|
| 108 |
Returns True on success, False on failure (never raises).
|
| 109 |
"""
|
| 110 |
+
if not SMTP_PASS or not SMTP_USER:
|
| 111 |
+
logger.warning("SMTP_USER or SMTP_PASS not set — skipping email notification")
|
| 112 |
return False
|
| 113 |
|
| 114 |
html_body = build_email_body(
|
requirements.txt
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 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 |
bcrypt==4.0.1
|
|
@@ -11,4 +9,3 @@ pillow>=10.0.0
|
|
| 11 |
numpy>=1.24.0
|
| 12 |
opencv-python-headless>=4.8.0
|
| 13 |
scikit-learn>=1.3.0
|
| 14 |
-
scipy>=1.11.0
|
|
|
|
| 1 |
fastapi>=0.104.0
|
| 2 |
uvicorn[standard]>=0.24.0
|
|
|
|
| 3 |
python-multipart>=0.0.6
|
| 4 |
sqlalchemy>=2.0.0
|
|
|
|
| 5 |
python-jose[cryptography]>=3.3.0
|
| 6 |
passlib[bcrypt]>=1.7.4
|
| 7 |
bcrypt==4.0.1
|
|
|
|
| 9 |
numpy>=1.24.0
|
| 10 |
opencv-python-headless>=4.8.0
|
| 11 |
scikit-learn>=1.3.0
|
|
|
static/js/app.js
CHANGED
|
@@ -358,9 +358,10 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
|
|
| 358 |
|
| 359 |
// ---- Show result ----
|
| 360 |
function readFileAsDataURL(file) {
|
| 361 |
-
return new Promise((resolve) => {
|
| 362 |
const reader = new FileReader();
|
| 363 |
reader.onload = () => resolve(reader.result);
|
|
|
|
| 364 |
reader.readAsDataURL(file);
|
| 365 |
});
|
| 366 |
}
|
|
@@ -386,11 +387,15 @@ function showResult(data) {
|
|
| 386 |
|
| 387 |
const locParts = [data.village, data.zone].filter(Boolean);
|
| 388 |
const locLabel = locParts.length ? locParts.join(', ') : '—';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
statsEl.innerHTML = `
|
| 391 |
-
<div class="stat-box"><div class="value">${
|
| 392 |
-
<div class="stat-box"><div class="value" title="${
|
| 393 |
-
<div class="stat-box"><div class="value" title="${
|
| 394 |
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 395 |
<div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
|
| 396 |
`;
|
|
@@ -620,7 +625,7 @@ async function loadHistory() {
|
|
| 620 |
<td class="thumb-cell">${afterThumb}</td>
|
| 621 |
<td class="thumb-cell">${resultThumb}</td>
|
| 622 |
<td class="stats-cell">${r.regionsCount} regions</td>
|
| 623 |
-
<td class="stats-cell">${r.changePercentage.toFixed(2)}%</td>
|
| 624 |
<td class="actions-cell">
|
| 625 |
<button type="button" class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openRunFromHistory(${r.id})">View</button>
|
| 626 |
<button type="button" class="btn-icon" title="Delete" onclick="event.stopPropagation(); confirmDelete(${r.id})">
|
|
|
|
| 358 |
|
| 359 |
// ---- Show result ----
|
| 360 |
function readFileAsDataURL(file) {
|
| 361 |
+
return new Promise((resolve, reject) => {
|
| 362 |
const reader = new FileReader();
|
| 363 |
reader.onload = () => resolve(reader.result);
|
| 364 |
+
reader.onerror = () => reject(reader.error);
|
| 365 |
reader.readAsDataURL(file);
|
| 366 |
});
|
| 367 |
}
|
|
|
|
| 387 |
|
| 388 |
const locParts = [data.village, data.zone].filter(Boolean);
|
| 389 |
const locLabel = locParts.length ? locParts.join(', ') : '—';
|
| 390 |
+
const stats = data.statistics || {};
|
| 391 |
+
const pct = (stats.changePercentage ?? 0).toFixed(2);
|
| 392 |
+
const chPx = stats.changedPixels ?? 0;
|
| 393 |
+
const totPx = stats.totalPixels ?? 0;
|
| 394 |
|
| 395 |
statsEl.innerHTML = `
|
| 396 |
+
<div class="stat-box"><div class="value">${pct}%</div><div class="label">Changed</div></div>
|
| 397 |
+
<div class="stat-box"><div class="value" title="${chPx.toLocaleString()}">${formatCompact(chPx)}</div><div class="label">Changed px</div></div>
|
| 398 |
+
<div class="stat-box"><div class="value" title="${totPx.toLocaleString()}">${formatCompact(totPx)}</div><div class="label">Total px</div></div>
|
| 399 |
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 400 |
<div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
|
| 401 |
`;
|
|
|
|
| 625 |
<td class="thumb-cell">${afterThumb}</td>
|
| 626 |
<td class="thumb-cell">${resultThumb}</td>
|
| 627 |
<td class="stats-cell">${r.regionsCount} regions</td>
|
| 628 |
+
<td class="stats-cell">${(r.changePercentage ?? 0).toFixed(2)}%</td>
|
| 629 |
<td class="actions-cell">
|
| 630 |
<button type="button" class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openRunFromHistory(${r.id})">View</button>
|
| 631 |
<button type="button" class="btn-icon" title="Delete" onclick="event.stopPropagation(); confirmDelete(${r.id})">
|
templates/index.html
CHANGED
|
@@ -345,6 +345,6 @@
|
|
| 345 |
</div>
|
| 346 |
</div>
|
| 347 |
|
| 348 |
-
<script src="/static/js/app.js?v=
|
| 349 |
</body>
|
| 350 |
</html>
|
|
|
|
| 345 |
</div>
|
| 346 |
</div>
|
| 347 |
|
| 348 |
+
<script src="/static/js/app.js?v=20"></script>
|
| 349 |
</body>
|
| 350 |
</html>
|