Spaces:
Sleeping
Sleeping
Commit ·
66006d5
1
Parent(s): deaa6d8
Deploy DDA jobs API, hierarchy panel, and region locate on dev.
Browse filesAsync job queue with sync fallback for Run Detection; zone/village tree in library sidebar; click-to-locate regions in result viewer.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +1 -1
- app/dda/bootstrap.py +3 -1
- app/dda/job_runner.py +210 -0
- app/dda/jobs_routes.py +182 -0
- app/dda/models.py +2 -2
- app/detection_engine.py +207 -66
- app/main.py +7 -3
- app/model_inference.py +27 -1
- requirements.txt +3 -2
- static/css/dda.css +30 -0
- static/js/dda/app.js +30 -0
- static/js/dda/compare.js +17 -1
- static/js/dda/result.js +47 -22
- templates/index_dda.html +10 -6
Dockerfile
CHANGED
|
@@ -21,7 +21,7 @@ WORKDIR /app
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
-
ARG APP_BUILD=
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
|
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
+
ARG APP_BUILD=33
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
app/dda/bootstrap.py
CHANGED
|
@@ -5,6 +5,7 @@ from sqlalchemy import text as sa_text
|
|
| 5 |
|
| 6 |
from ..database import engine
|
| 7 |
from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
|
|
|
|
| 8 |
from .library_routes import router as library_router
|
| 9 |
from .local_routes import router as local_router
|
| 10 |
from .seed import seed_delhi_hierarchy
|
|
@@ -53,5 +54,6 @@ def setup_dda(app: FastAPI) -> None:
|
|
| 53 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 54 |
return
|
| 55 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
|
|
|
| 56 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 57 |
-
logger.info("APP_MODE=dda — DDA routes enabled (
|
|
|
|
| 5 |
|
| 6 |
from ..database import engine
|
| 7 |
from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
|
| 8 |
+
from .jobs_routes import router as jobs_router
|
| 9 |
from .library_routes import router as library_router
|
| 10 |
from .local_routes import router as local_router
|
| 11 |
from .seed import seed_delhi_hierarchy
|
|
|
|
| 54 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 55 |
return
|
| 56 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 57 |
+
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
| 58 |
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 59 |
+
logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, local folder)")
|
app/dda/job_runner.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background detection job runner (FR-04 async pipeline)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import threading
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from ..auth import get_or_create_guest_user
|
| 15 |
+
from ..database import SessionLocal
|
| 16 |
+
from ..models import DetectionRun
|
| 17 |
+
from .config import get_detection_max_side
|
| 18 |
+
from .detect_service import run_detection_and_save
|
| 19 |
+
from .geotiff_io import load_rgb_pil
|
| 20 |
+
from .local_library import safe_resolve
|
| 21 |
+
from .models import DetectionJob
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
_runner_lock = threading.Lock()
|
| 26 |
+
_active_job_id: Optional[int] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _utcnow():
|
| 30 |
+
return datetime.now(timezone.utc)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _load_pair(base_path: str, comparison_path: str) -> tuple[Image.Image, Image.Image, Path]:
|
| 34 |
+
base_file = safe_resolve(base_path)
|
| 35 |
+
comp_file = safe_resolve(comparison_path)
|
| 36 |
+
max_side = get_detection_max_side()
|
| 37 |
+
before_pil = load_rgb_pil(base_file, max_side=max_side)
|
| 38 |
+
after_pil = load_rgb_pil(comp_file, max_side=max_side)
|
| 39 |
+
if before_pil.size != after_pil.size:
|
| 40 |
+
after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
|
| 41 |
+
return before_pil, after_pil, base_file
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _parse_params(job: DetectionJob) -> Dict[str, Any]:
|
| 45 |
+
try:
|
| 46 |
+
return json.loads(job.params_json or "{}")
|
| 47 |
+
except json.JSONDecodeError:
|
| 48 |
+
return {}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _run_job_sync(job_id: int) -> None:
|
| 52 |
+
global _active_job_id
|
| 53 |
+
db = SessionLocal()
|
| 54 |
+
try:
|
| 55 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 56 |
+
if not job or job.status not in ("queued", "running"):
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
job.status = "running"
|
| 60 |
+
job.started_at = _utcnow()
|
| 61 |
+
job.error_message = ""
|
| 62 |
+
db.commit()
|
| 63 |
+
|
| 64 |
+
params = _parse_params(job)
|
| 65 |
+
base_path = params.get("base_path", "")
|
| 66 |
+
comparison_path = params.get("comparison_path", "")
|
| 67 |
+
if not base_path or not comparison_path:
|
| 68 |
+
raise ValueError("Job missing base_path or comparison_path in params_json")
|
| 69 |
+
|
| 70 |
+
before_pil, after_pil, base_file = _load_pair(base_path, comparison_path)
|
| 71 |
+
title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}"
|
| 72 |
+
result = run_detection_and_save(
|
| 73 |
+
db,
|
| 74 |
+
before_pil,
|
| 75 |
+
after_pil,
|
| 76 |
+
method=job.method or params.get("method", "AI-Based Deep Learning"),
|
| 77 |
+
title=title,
|
| 78 |
+
zone=params.get("zone", ""),
|
| 79 |
+
village=params.get("village", ""),
|
| 80 |
+
enable_registration=bool(params.get("enable_registration", True)),
|
| 81 |
+
enable_normalization=bool(params.get("enable_normalization", True)),
|
| 82 |
+
detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
|
| 83 |
+
min_region_area=params.get("min_region_area"),
|
| 84 |
+
notify_email=job.notify_email or params.get("notify_email"),
|
| 85 |
+
max_size=get_detection_max_side(),
|
| 86 |
+
geo_bounds_path=base_file,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
job.status = "completed"
|
| 90 |
+
job.run_id = result["id"]
|
| 91 |
+
job.completed_at = _utcnow()
|
| 92 |
+
db.commit()
|
| 93 |
+
logger.info("Detection job %d completed → run %s", job_id, result["id"])
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
logger.exception("Detection job %d failed", job_id)
|
| 96 |
+
try:
|
| 97 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 98 |
+
if job:
|
| 99 |
+
job.status = "failed"
|
| 100 |
+
job.error_message = str(exc)[:2000]
|
| 101 |
+
job.completed_at = _utcnow()
|
| 102 |
+
db.commit()
|
| 103 |
+
except Exception:
|
| 104 |
+
db.rollback()
|
| 105 |
+
finally:
|
| 106 |
+
with _runner_lock:
|
| 107 |
+
if _active_job_id == job_id:
|
| 108 |
+
_active_job_id = None
|
| 109 |
+
db.close()
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _job_worker(job_id: int) -> None:
|
| 113 |
+
global _active_job_id
|
| 114 |
+
with _runner_lock:
|
| 115 |
+
_active_job_id = job_id
|
| 116 |
+
try:
|
| 117 |
+
_run_job_sync(job_id)
|
| 118 |
+
finally:
|
| 119 |
+
with _runner_lock:
|
| 120 |
+
if _active_job_id == job_id:
|
| 121 |
+
_active_job_id = None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def enqueue_detection_job(job_id: int) -> bool:
|
| 125 |
+
"""Start job in a background thread. Returns False if another job is running."""
|
| 126 |
+
global _active_job_id
|
| 127 |
+
with _runner_lock:
|
| 128 |
+
if _active_job_id is not None:
|
| 129 |
+
return False
|
| 130 |
+
_active_job_id = job_id
|
| 131 |
+
thread = threading.Thread(target=_job_worker, args=(job_id,), daemon=True, name=f"dda-job-{job_id}")
|
| 132 |
+
thread.start()
|
| 133 |
+
return True
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def is_job_runner_busy() -> bool:
|
| 137 |
+
with _runner_lock:
|
| 138 |
+
return _active_job_id is not None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def create_local_folder_job(
|
| 142 |
+
db: Session,
|
| 143 |
+
*,
|
| 144 |
+
base_path: str,
|
| 145 |
+
comparison_path: str,
|
| 146 |
+
method: str = "AI-Based Deep Learning",
|
| 147 |
+
title: str = "",
|
| 148 |
+
zone: str = "",
|
| 149 |
+
village: str = "",
|
| 150 |
+
enable_registration: bool = True,
|
| 151 |
+
enable_normalization: bool = True,
|
| 152 |
+
detection_sensitivity: float = 0.45,
|
| 153 |
+
min_region_area: Optional[int] = 150,
|
| 154 |
+
notify_email: str = "",
|
| 155 |
+
created_by: Optional[int] = None,
|
| 156 |
+
) -> DetectionJob:
|
| 157 |
+
user = get_or_create_guest_user(db)
|
| 158 |
+
params = {
|
| 159 |
+
"source": "local_folder",
|
| 160 |
+
"base_path": base_path.replace("\\", "/"),
|
| 161 |
+
"comparison_path": comparison_path.replace("\\", "/"),
|
| 162 |
+
"method": method,
|
| 163 |
+
"title": title,
|
| 164 |
+
"zone": zone,
|
| 165 |
+
"village": village,
|
| 166 |
+
"enable_registration": enable_registration,
|
| 167 |
+
"enable_normalization": enable_normalization,
|
| 168 |
+
"detection_sensitivity": detection_sensitivity,
|
| 169 |
+
"min_region_area": min_region_area,
|
| 170 |
+
}
|
| 171 |
+
job = DetectionJob(
|
| 172 |
+
status="queued",
|
| 173 |
+
base_image_id=None,
|
| 174 |
+
comparison_image_id=None,
|
| 175 |
+
method=method,
|
| 176 |
+
params_json=json.dumps(params),
|
| 177 |
+
notify_email=notify_email or "",
|
| 178 |
+
created_by=created_by or user.id,
|
| 179 |
+
)
|
| 180 |
+
db.add(job)
|
| 181 |
+
db.commit()
|
| 182 |
+
db.refresh(job)
|
| 183 |
+
return job
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
|
| 187 |
+
params = _parse_params(job)
|
| 188 |
+
out = {
|
| 189 |
+
"id": job.id,
|
| 190 |
+
"status": job.status,
|
| 191 |
+
"method": job.method,
|
| 192 |
+
"basePath": params.get("base_path", ""),
|
| 193 |
+
"comparisonPath": params.get("comparison_path", ""),
|
| 194 |
+
"title": params.get("title", ""),
|
| 195 |
+
"runId": job.run_id,
|
| 196 |
+
"errorMessage": job.error_message or "",
|
| 197 |
+
"notifyEmail": job.notify_email or "",
|
| 198 |
+
"createdAt": job.created_at.isoformat() if job.created_at else None,
|
| 199 |
+
"startedAt": job.started_at.isoformat() if job.started_at else None,
|
| 200 |
+
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
|
| 201 |
+
}
|
| 202 |
+
if run:
|
| 203 |
+
out["report"] = {
|
| 204 |
+
"id": run.id,
|
| 205 |
+
"title": run.title,
|
| 206 |
+
"changePercentage": run.change_percentage,
|
| 207 |
+
"regionsCount": run.regions_count,
|
| 208 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 209 |
+
}
|
| 210 |
+
return out
|
app/dda/jobs_routes.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async detection job API (FR-04)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Form, HTTPException, Query
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from ..auth import get_or_create_guest_user
|
| 12 |
+
from ..database import get_db
|
| 13 |
+
from ..models import DetectionRun
|
| 14 |
+
from .job_runner import (
|
| 15 |
+
create_local_folder_job,
|
| 16 |
+
enqueue_detection_job,
|
| 17 |
+
is_job_runner_busy,
|
| 18 |
+
job_to_dict,
|
| 19 |
+
)
|
| 20 |
+
from .local_library import safe_resolve
|
| 21 |
+
from .models import DetectionJob
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
router = APIRouter()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _require_dda():
|
| 28 |
+
from .config import IS_DDA_MODE
|
| 29 |
+
if not IS_DDA_MODE:
|
| 30 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post("/jobs")
|
| 34 |
+
async def create_job(
|
| 35 |
+
base_path: str = Form(...),
|
| 36 |
+
comparison_path: str = Form(...),
|
| 37 |
+
method: str = Form("AI-Based Deep Learning"),
|
| 38 |
+
title: str = Form(""),
|
| 39 |
+
zone: str = Form(""),
|
| 40 |
+
village: str = Form(""),
|
| 41 |
+
enable_registration: bool = Form(True),
|
| 42 |
+
enable_normalization: bool = Form(True),
|
| 43 |
+
detection_sensitivity: float = Form(0.45),
|
| 44 |
+
min_region_area: Optional[int] = Form(150),
|
| 45 |
+
notify_email: Optional[str] = Form(None),
|
| 46 |
+
db: Session = Depends(get_db),
|
| 47 |
+
):
|
| 48 |
+
"""Queue async detection from local library paths. Returns immediately with jobId."""
|
| 49 |
+
_require_dda()
|
| 50 |
+
base_norm = base_path.replace("\\", "/").strip()
|
| 51 |
+
comp_norm = comparison_path.replace("\\", "/").strip()
|
| 52 |
+
if not base_norm or not comp_norm:
|
| 53 |
+
raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
|
| 54 |
+
if base_norm == comp_norm:
|
| 55 |
+
raise HTTPException(status_code=400, detail="Base and comparison images must be different")
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
safe_resolve(base_norm)
|
| 59 |
+
safe_resolve(comp_norm)
|
| 60 |
+
except HTTPException:
|
| 61 |
+
raise
|
| 62 |
+
except Exception as exc:
|
| 63 |
+
raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
|
| 64 |
+
|
| 65 |
+
if is_job_runner_busy():
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=409,
|
| 68 |
+
detail="Another detection job is already running. Wait for it to finish, then try again.",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
user = get_or_create_guest_user(db)
|
| 72 |
+
if not title.strip():
|
| 73 |
+
from pathlib import Path
|
| 74 |
+
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 75 |
+
|
| 76 |
+
job = create_local_folder_job(
|
| 77 |
+
db,
|
| 78 |
+
base_path=base_norm,
|
| 79 |
+
comparison_path=comp_norm,
|
| 80 |
+
method=method,
|
| 81 |
+
title=title,
|
| 82 |
+
zone=zone,
|
| 83 |
+
village=village,
|
| 84 |
+
enable_registration=enable_registration,
|
| 85 |
+
enable_normalization=enable_normalization,
|
| 86 |
+
detection_sensitivity=detection_sensitivity,
|
| 87 |
+
min_region_area=min_region_area,
|
| 88 |
+
notify_email=notify_email or "",
|
| 89 |
+
created_by=user.id,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if not enqueue_detection_job(job.id):
|
| 93 |
+
job.status = "failed"
|
| 94 |
+
job.error_message = "Could not start background worker"
|
| 95 |
+
db.commit()
|
| 96 |
+
raise HTTPException(status_code=503, detail="Job queue is busy")
|
| 97 |
+
|
| 98 |
+
return {"jobId": job.id, "status": "queued", "message": "Detection job queued. Poll GET /api/dda/jobs/{id} for status."}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.get("/jobs/{job_id}")
|
| 102 |
+
def get_job(job_id: int, db: Session = Depends(get_db)):
|
| 103 |
+
_require_dda()
|
| 104 |
+
user = get_or_create_guest_user(db)
|
| 105 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 106 |
+
if not job:
|
| 107 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 108 |
+
if job.created_by and job.created_by != user.id:
|
| 109 |
+
raise HTTPException(status_code=403, detail="Not allowed to view this job")
|
| 110 |
+
|
| 111 |
+
run = None
|
| 112 |
+
if job.run_id:
|
| 113 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first()
|
| 114 |
+
|
| 115 |
+
data = job_to_dict(job, run=run)
|
| 116 |
+
if job.status == "completed" and run:
|
| 117 |
+
try:
|
| 118 |
+
data["result"] = _run_detail(db, run, user.id)
|
| 119 |
+
except HTTPException:
|
| 120 |
+
raise
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
logger.warning("Could not load full run for job %s: %s", job_id, exc)
|
| 123 |
+
return data
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
|
| 127 |
+
import base64
|
| 128 |
+
|
| 129 |
+
from ..database import DATA_DIR
|
| 130 |
+
|
| 131 |
+
if run.user_id != user_id:
|
| 132 |
+
raise HTTPException(status_code=403, detail="Not allowed")
|
| 133 |
+
|
| 134 |
+
regions = json.loads(run.regions_json or "[]")
|
| 135 |
+
overlay_b64 = ""
|
| 136 |
+
if run.overlay_path:
|
| 137 |
+
overlay_file = DATA_DIR / run.overlay_path
|
| 138 |
+
if overlay_file.exists():
|
| 139 |
+
overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
|
| 140 |
+
|
| 141 |
+
from .detect_service import _isoformat_ist
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"id": run.id,
|
| 145 |
+
"title": run.title,
|
| 146 |
+
"method": run.method,
|
| 147 |
+
"zone": run.zone or "",
|
| 148 |
+
"village": run.village or "",
|
| 149 |
+
"statistics": {
|
| 150 |
+
"totalPixels": run.total_pixels,
|
| 151 |
+
"changedPixels": run.changed_pixels,
|
| 152 |
+
"unchangedPixels": run.total_pixels - run.changed_pixels,
|
| 153 |
+
"changePercentage": run.change_percentage,
|
| 154 |
+
},
|
| 155 |
+
"regions": regions,
|
| 156 |
+
"overlayBase64Png": overlay_b64,
|
| 157 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 158 |
+
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 159 |
+
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 160 |
+
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
| 161 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.get("/jobs")
|
| 166 |
+
def list_jobs(
|
| 167 |
+
status: Optional[str] = Query(None),
|
| 168 |
+
limit: int = Query(20, ge=1, le=100),
|
| 169 |
+
db: Session = Depends(get_db),
|
| 170 |
+
):
|
| 171 |
+
"""Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
|
| 172 |
+
_require_dda()
|
| 173 |
+
user = get_or_create_guest_user(db)
|
| 174 |
+
q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
|
| 175 |
+
if status:
|
| 176 |
+
q = q.filter(DetectionJob.status == status)
|
| 177 |
+
jobs = q.order_by(DetectionJob.created_at.desc()).limit(limit).all()
|
| 178 |
+
out = []
|
| 179 |
+
for job in jobs:
|
| 180 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first() if job.run_id else None
|
| 181 |
+
out.append(job_to_dict(job, run=run))
|
| 182 |
+
return {"jobs": out, "runnerBusy": is_job_runner_busy()}
|
app/dda/models.py
CHANGED
|
@@ -77,8 +77,8 @@ class DetectionJob(Base):
|
|
| 77 |
|
| 78 |
id = Column(Integer, primary_key=True, index=True)
|
| 79 |
status = Column(String(32), default="queued", index=True) # queued|running|completed|failed
|
| 80 |
-
base_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=
|
| 81 |
-
comparison_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=
|
| 82 |
method = Column(String(64), default="AI-Based Deep Learning")
|
| 83 |
params_json = Column(Text, default="{}")
|
| 84 |
run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=True)
|
|
|
|
| 77 |
|
| 78 |
id = Column(Integer, primary_key=True, index=True)
|
| 79 |
status = Column(String(32), default="queued", index=True) # queued|running|completed|failed
|
| 80 |
+
base_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
|
| 81 |
+
comparison_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
|
| 82 |
method = Column(String(64), default="AI-Based Deep Learning")
|
| 83 |
params_json = Column(Text, default="{}")
|
| 84 |
run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=True)
|
app/detection_engine.py
CHANGED
|
@@ -20,6 +20,18 @@ _log = logging.getLogger(__name__)
|
|
| 20 |
# 1. Pre-processing
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def _ensure_rgb_uint8(img_array):
|
| 24 |
"""Convert any image array to 3-channel RGB uint8."""
|
| 25 |
if img_array.ndim == 2:
|
|
@@ -38,8 +50,10 @@ def _to_float32(img):
|
|
| 38 |
return img.astype(np.float32) / 255.0
|
| 39 |
|
| 40 |
|
| 41 |
-
def preprocess_image(image, max_size=
|
| 42 |
-
"""Preprocess image: convert to RGB, limit size, light
|
|
|
|
|
|
|
| 43 |
img_array = np.array(image)
|
| 44 |
img_array = _ensure_rgb_uint8(img_array)
|
| 45 |
|
|
@@ -49,10 +63,12 @@ def preprocess_image(image, max_size=1600):
|
|
| 49 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 50 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
| 54 |
lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
| 55 |
-
if lap_var <
|
| 56 |
img_array = cv2.bilateralFilter(img_array, 5, 50, 50)
|
| 57 |
return img_array
|
| 58 |
|
|
@@ -774,7 +790,8 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
|
|
| 774 |
img1, img2, registration_ok=registration_ok)
|
| 775 |
|
| 776 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 777 |
-
|
|
|
|
| 778 |
thr_score = float(np.quantile(classical_score, q))
|
| 779 |
change_mask = (classical_score >= thr_score).astype(np.uint8) * 255
|
| 780 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
|
@@ -796,40 +813,134 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
|
|
| 796 |
return change_mask, classical_score, debug
|
| 797 |
|
| 798 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
def ai_deep_learning_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 800 |
-
"""
|
|
|
|
|
|
|
| 801 |
from .model_inference import is_model_available, predict_change_mask
|
| 802 |
|
|
|
|
| 803 |
dl_score = None
|
| 804 |
model_ok = False
|
| 805 |
-
|
| 806 |
|
| 807 |
if is_model_available():
|
| 808 |
try:
|
| 809 |
-
|
| 810 |
model_ok = dl_score is not None
|
| 811 |
except Exception as e:
|
| 812 |
_log.warning("AdaptFormer inference failed: %s", e)
|
| 813 |
|
| 814 |
-
classical_score,
|
| 815 |
-
img1, img2, registration_ok=registration_ok)
|
| 816 |
|
| 817 |
if model_ok and dl_score is not None:
|
| 818 |
-
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
| 820 |
debug = {
|
| 821 |
-
"method": "AI-Based Deep Learning (AdaptFormer +
|
| 822 |
"model": "adaptformer-levir-cd",
|
| 823 |
-
"
|
|
|
|
| 824 |
"sensitivity": float(sensitivity),
|
| 825 |
-
|
|
|
|
|
|
|
| 826 |
}
|
| 827 |
return combined, debug
|
| 828 |
|
| 829 |
-
rule_mask, _, core_debug = _ai_fusion_core(
|
| 830 |
-
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 831 |
debug = {
|
| 832 |
"method": "AI-Based Deep Learning (classical fallback)",
|
|
|
|
| 833 |
"sensitivity": float(sensitivity),
|
| 834 |
"core": core_debug,
|
| 835 |
}
|
|
@@ -852,9 +963,9 @@ def hybrid_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
|
| 852 |
0.5 * ai_mask.astype(np.float32)
|
| 853 |
)
|
| 854 |
|
| 855 |
-
base_thr =
|
| 856 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 857 |
-
hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36),
|
| 858 |
_, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
|
| 859 |
final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
|
| 860 |
debug = {
|
|
@@ -901,49 +1012,73 @@ def _build_confidence_map_from_channels(img1, img2, dl_score=None):
|
|
| 901 |
return build_confidence_map(channels, weights)
|
| 902 |
|
| 903 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
def hybrid_ai_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 905 |
-
"""Hybrid AI:
|
| 906 |
if img1.shape != img2.shape:
|
| 907 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 908 |
|
| 909 |
from .model_inference import is_model_available, predict_change_mask
|
| 910 |
|
| 911 |
-
|
|
|
|
| 912 |
dl_method = "none"
|
|
|
|
| 913 |
|
| 914 |
if is_model_available():
|
| 915 |
try:
|
| 916 |
-
|
| 917 |
dl_method = "adaptformer"
|
| 918 |
except Exception:
|
| 919 |
pass
|
| 920 |
|
| 921 |
if dl_method == "none":
|
| 922 |
try:
|
| 923 |
-
from .cd_models.change_model import
|
| 924 |
-
if
|
| 925 |
-
|
| 926 |
dl_method = "siamese_unet"
|
| 927 |
except Exception:
|
| 928 |
pass
|
| 929 |
|
| 930 |
-
|
| 931 |
-
img1, img2, registration_ok=registration_ok)
|
|
|
|
|
|
|
| 932 |
|
| 933 |
-
if dl_method != "none"
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
debug = {
|
| 937 |
-
"method": f"Hybrid AI ({dl_method} + gated fusion)",
|
| 938 |
-
"dl_method": dl_method,
|
| 939 |
-
"sensitivity": float(sensitivity),
|
| 940 |
-
**fuse_debug,
|
| 941 |
-
}
|
| 942 |
-
return final_mask, debug
|
| 943 |
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 947 |
|
| 948 |
|
| 949 |
ALIGNMENT_WARNING_MSG = (
|
|
@@ -999,7 +1134,7 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 999 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 1000 |
|
| 1001 |
# 7. Component-level filtering: remove tiny survivors and elongated noise
|
| 1002 |
-
min_component_px = max(
|
| 1003 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
|
| 1004 |
clean = np.zeros_like(filled)
|
| 1005 |
for i in range(1, num_labels):
|
|
@@ -1370,20 +1505,23 @@ def classify_object_type(image_region, bbox, before_region=None):
|
|
| 1370 |
|
| 1371 |
# ---- Water Body Change ----
|
| 1372 |
water = 0.0
|
| 1373 |
-
if feat_a
|
| 1374 |
-
water
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
|
|
|
|
|
|
|
|
|
| 1387 |
scores["Water Body Change"] = water
|
| 1388 |
|
| 1389 |
# ---- Vegetation Change ----
|
|
@@ -1663,10 +1801,9 @@ def classify_object_type(image_region, bbox, before_region=None):
|
|
| 1663 |
soil += 0.10
|
| 1664 |
scores["Bare Land/Soil Change"] = soil
|
| 1665 |
|
| 1666 |
-
best =
|
| 1667 |
-
conf = scores[best]
|
| 1668 |
|
| 1669 |
-
if conf < 0.
|
| 1670 |
return "Unclassified", conf
|
| 1671 |
return best, min(conf, 1.0)
|
| 1672 |
|
|
@@ -2356,7 +2493,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 2356 |
# - keeps sensitivity on smaller images
|
| 2357 |
# - suppresses speckle noise on larger images
|
| 2358 |
if min_area is None:
|
| 2359 |
-
min_area = int(max(
|
| 2360 |
|
| 2361 |
for i in range(1, num_labels):
|
| 2362 |
raw_area = stats[i, cv2.CC_STAT_AREA]
|
|
@@ -2366,7 +2503,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 2366 |
x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
|
| 2367 |
|
| 2368 |
# Reject very sparse regions (bbox is mostly empty)
|
| 2369 |
-
if fill_ratio < 0.
|
| 2370 |
continue
|
| 2371 |
|
| 2372 |
# Keep large real changes; only suppress near-full-frame artifacts.
|
|
@@ -2385,14 +2522,16 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 2385 |
image, (x, y, w, h), before_region=before_img)
|
| 2386 |
|
| 2387 |
if object_type is None:
|
| 2388 |
-
#
|
| 2389 |
-
|
| 2390 |
-
if raw_area >= max(min_area * 2, 800) and fill_ratio >= 0.18:
|
| 2391 |
object_type = "Unclassified Ground Change"
|
| 2392 |
confidence = max(0.2, min(0.5, fill_ratio))
|
| 2393 |
else:
|
| 2394 |
continue
|
| 2395 |
|
|
|
|
|
|
|
|
|
|
| 2396 |
region_id += 1
|
| 2397 |
region = {
|
| 2398 |
"id": region_id,
|
|
@@ -2456,10 +2595,12 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 2456 |
|
| 2457 |
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
| 2458 |
enable_registration=True, enable_normalization=True,
|
| 2459 |
-
detection_sensitivity=0.5, min_region_area=None
|
|
|
|
| 2460 |
"""Run full detection pipeline; returns change_mask, result_image, stats, regions."""
|
| 2461 |
-
|
| 2462 |
-
|
|
|
|
| 2463 |
|
| 2464 |
registration_ok = False
|
| 2465 |
reg_meta = {}
|
|
|
|
| 20 |
# 1. Pre-processing
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
+
def get_detection_max_size() -> int:
|
| 24 |
+
"""Max pixel dimension for detection (override with DETECTION_MAX_SIDE env)."""
|
| 25 |
+
import os
|
| 26 |
+
hosted = bool(os.environ.get("SPACE_ID", "").strip())
|
| 27 |
+
default = "2048" if hosted else "4096"
|
| 28 |
+
try:
|
| 29 |
+
value = int(os.environ.get("DETECTION_MAX_SIDE", default))
|
| 30 |
+
except ValueError:
|
| 31 |
+
value = int(default)
|
| 32 |
+
return max(1024, min(8192, value))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
def _ensure_rgb_uint8(img_array):
|
| 36 |
"""Convert any image array to 3-channel RGB uint8."""
|
| 37 |
if img_array.ndim == 2:
|
|
|
|
| 50 |
return img.astype(np.float32) / 255.0
|
| 51 |
|
| 52 |
|
| 53 |
+
def preprocess_image(image, max_size=None):
|
| 54 |
+
"""Preprocess image: convert to RGB, limit size, light denoise."""
|
| 55 |
+
if max_size is None:
|
| 56 |
+
max_size = get_detection_max_size()
|
| 57 |
img_array = np.array(image)
|
| 58 |
img_array = _ensure_rgb_uint8(img_array)
|
| 59 |
|
|
|
|
| 63 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 64 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 65 |
|
| 66 |
+
# Light denoise — smaller kernel preserves fine change detail at high resolution
|
| 67 |
+
blur_ksize = 3 if max_size >= 3000 else 5
|
| 68 |
+
img_array = cv2.GaussianBlur(img_array, (blur_ksize, blur_ksize), 0)
|
| 69 |
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
| 70 |
lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
| 71 |
+
if lap_var < 60.0:
|
| 72 |
img_array = cv2.bilateralFilter(img_array, 5, 50, 50)
|
| 73 |
return img_array
|
| 74 |
|
|
|
|
| 790 |
img1, img2, registration_ok=registration_ok)
|
| 791 |
|
| 792 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 793 |
+
# Looser percentile than gated fusion — keeps recall for multi-region detection
|
| 794 |
+
q = float(np.clip(0.93 - (sens - 0.5) * 0.06, 0.85, 0.96))
|
| 795 |
thr_score = float(np.quantile(classical_score, q))
|
| 796 |
change_mask = (classical_score >= thr_score).astype(np.uint8) * 255
|
| 797 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
|
|
|
| 813 |
return change_mask, classical_score, debug
|
| 814 |
|
| 815 |
|
| 816 |
+
def _smart_union_fusion(model_mask, rule_mask, dl_score, classical_score, sensitivity=0.5):
|
| 817 |
+
"""
|
| 818 |
+
Union with confidence pruning: keep pixels where at least one engine is
|
| 819 |
+
confident, or both agree. Drops weak single-engine speckle (hallucinations).
|
| 820 |
+
"""
|
| 821 |
+
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 822 |
+
model_on = model_mask > 127
|
| 823 |
+
rule_on = rule_mask > 127
|
| 824 |
+
both_agree = model_on & rule_on
|
| 825 |
+
|
| 826 |
+
dl_floor = 0.32 + (1.0 - sens) * 0.10
|
| 827 |
+
cl_q = float(np.clip(0.91 - (sens - 0.5) * 0.03, 0.87, 0.94))
|
| 828 |
+
cl_floor = (
|
| 829 |
+
float(np.quantile(classical_score, cl_q))
|
| 830 |
+
if float(classical_score.max()) > 1e-6 else 0.38
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
dl_ok = dl_score >= dl_floor
|
| 834 |
+
cl_ok = classical_score >= cl_floor
|
| 835 |
+
keep = both_agree | (model_on & dl_ok) | (rule_on & cl_ok)
|
| 836 |
+
return np.where(keep, 255, 0).astype(np.uint8)
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
def _structural_evidence(diff, feat_a):
|
| 840 |
+
"""Score how strongly a region looks like built structure (not water/vegetation)."""
|
| 841 |
+
score = 0.0
|
| 842 |
+
if diff:
|
| 843 |
+
if diff.get("delta_lines", 0) > 2:
|
| 844 |
+
score += 0.28
|
| 845 |
+
if diff.get("delta_corners", 0) > 3:
|
| 846 |
+
score += 0.24
|
| 847 |
+
if diff.get("delta_edge_density", 0) > 8:
|
| 848 |
+
score += 0.20
|
| 849 |
+
if diff.get("hull_ratio_after", 0) > 0.35:
|
| 850 |
+
score += 0.18
|
| 851 |
+
if diff.get("lines_after", 0) > 4:
|
| 852 |
+
score += 0.14
|
| 853 |
+
if diff.get("ssim", 1.0) < 0.65:
|
| 854 |
+
score += 0.12
|
| 855 |
+
if feat_a.get("edge_density", 0) > 35:
|
| 856 |
+
score += 0.18
|
| 857 |
+
if feat_a.get("orientation_entropy", 3.0) < 2.4:
|
| 858 |
+
score += 0.14
|
| 859 |
+
return min(1.0, score)
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
def _resolve_classification(scores, diff, feat_a):
|
| 863 |
+
"""Apply cross-type constraints; fix water vs construction confusion."""
|
| 864 |
+
structural = _structural_evidence(diff, feat_a)
|
| 865 |
+
|
| 866 |
+
water = scores.get("Water Body Change", 0.0)
|
| 867 |
+
bld = scores.get("New Construction/Building", 0.0)
|
| 868 |
+
|
| 869 |
+
# Water needs smooth, blue, low-edge surface — not just one cue
|
| 870 |
+
water_cues = sum([
|
| 871 |
+
feat_a["blue_ratio"] > 0.38,
|
| 872 |
+
feat_a["edge_density"] < 28,
|
| 873 |
+
feat_a["texture_std"] < 26,
|
| 874 |
+
95 <= feat_a["hue"] <= 130,
|
| 875 |
+
feat_a["lbp_variance"] < 0.045,
|
| 876 |
+
])
|
| 877 |
+
if water_cues < 3:
|
| 878 |
+
scores["Water Body Change"] = water * 0.45
|
| 879 |
+
elif water_cues < 4:
|
| 880 |
+
scores["Water Body Change"] = water * 0.75
|
| 881 |
+
|
| 882 |
+
# Built structure strongly disqualifies water
|
| 883 |
+
if structural >= 0.30:
|
| 884 |
+
scores["Water Body Change"] *= max(0.1, 1.0 - structural * 1.2)
|
| 885 |
+
scores["New Construction/Building"] = min(1.0, bld + structural * 0.45)
|
| 886 |
+
|
| 887 |
+
best = max(scores, key=scores.get)
|
| 888 |
+
conf = scores[best]
|
| 889 |
+
|
| 890 |
+
# Prefer construction when structural evidence is strong and scores are close
|
| 891 |
+
if (
|
| 892 |
+
best == "Water Body Change"
|
| 893 |
+
and scores["New Construction/Building"] >= conf * 0.72
|
| 894 |
+
and structural >= 0.22
|
| 895 |
+
):
|
| 896 |
+
best = "New Construction/Building"
|
| 897 |
+
conf = scores["New Construction/Building"]
|
| 898 |
+
|
| 899 |
+
return best, conf
|
| 900 |
+
|
| 901 |
+
|
| 902 |
def ai_deep_learning_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 903 |
+
"""
|
| 904 |
+
Dual-engine: AdaptFormer + classical fusion with confidence-pruned union.
|
| 905 |
+
"""
|
| 906 |
from .model_inference import is_model_available, predict_change_mask
|
| 907 |
|
| 908 |
+
model_mask = None
|
| 909 |
dl_score = None
|
| 910 |
model_ok = False
|
| 911 |
+
threshold = 0.30 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.22
|
| 912 |
|
| 913 |
if is_model_available():
|
| 914 |
try:
|
| 915 |
+
model_mask, dl_score = predict_change_mask(img1, img2, threshold=threshold)
|
| 916 |
model_ok = dl_score is not None
|
| 917 |
except Exception as e:
|
| 918 |
_log.warning("AdaptFormer inference failed: %s", e)
|
| 919 |
|
| 920 |
+
rule_mask, classical_score, core_debug = _ai_fusion_core(
|
| 921 |
+
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 922 |
|
| 923 |
if model_ok and dl_score is not None:
|
| 924 |
+
if model_mask is None:
|
| 925 |
+
model_mask = (dl_score >= threshold).astype(np.uint8) * 255
|
| 926 |
+
combined = _smart_union_fusion(
|
| 927 |
+
model_mask, rule_mask, dl_score, classical_score, sensitivity=sensitivity)
|
| 928 |
+
combined = _clean_mask(combined, sensitivity=sensitivity)
|
| 929 |
debug = {
|
| 930 |
+
"method": "AI-Based Deep Learning (AdaptFormer + confidence union)",
|
| 931 |
"model": "adaptformer-levir-cd",
|
| 932 |
+
"fusion": "smart_union",
|
| 933 |
+
"threshold_used": int(threshold * 255),
|
| 934 |
"sensitivity": float(sensitivity),
|
| 935 |
+
"model_changed_px": int(np.sum(model_mask > 127)),
|
| 936 |
+
"rule_changed_px": int(np.sum(rule_mask > 127)),
|
| 937 |
+
"combined_changed_px": int(np.sum(combined > 127)),
|
| 938 |
}
|
| 939 |
return combined, debug
|
| 940 |
|
|
|
|
|
|
|
| 941 |
debug = {
|
| 942 |
"method": "AI-Based Deep Learning (classical fallback)",
|
| 943 |
+
"threshold_used": core_debug.get("threshold_used"),
|
| 944 |
"sensitivity": float(sensitivity),
|
| 945 |
"core": core_debug,
|
| 946 |
}
|
|
|
|
| 963 |
0.5 * ai_mask.astype(np.float32)
|
| 964 |
)
|
| 965 |
|
| 966 |
+
base_thr = 98
|
| 967 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 968 |
+
hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36), 60, 150))
|
| 969 |
_, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
|
| 970 |
final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
|
| 971 |
debug = {
|
|
|
|
| 1012 |
return build_confidence_map(channels, weights)
|
| 1013 |
|
| 1014 |
|
| 1015 |
+
def _multiscale_classical(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 1016 |
+
"""Run classical fusion at multiple scales and OR-combine for better recall."""
|
| 1017 |
+
from .cd_models.model_utils import multiscale_detect
|
| 1018 |
+
|
| 1019 |
+
def _single_scale_detect(s1, s2):
|
| 1020 |
+
mask, _, _ = _ai_fusion_core(
|
| 1021 |
+
s1, s2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 1022 |
+
return mask
|
| 1023 |
+
|
| 1024 |
+
return multiscale_detect(_single_scale_detect, img1, img2, scales=(1.0, 0.5))
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
def hybrid_ai_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 1028 |
+
"""Hybrid AI: DL mask + multi-scale classical mask with confidence weighting."""
|
| 1029 |
if img1.shape != img2.shape:
|
| 1030 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 1031 |
|
| 1032 |
from .model_inference import is_model_available, predict_change_mask
|
| 1033 |
|
| 1034 |
+
dl_mask = np.zeros(img1.shape[:2], dtype=np.uint8)
|
| 1035 |
+
dl_score = np.zeros(img1.shape[:2], dtype=np.float32)
|
| 1036 |
dl_method = "none"
|
| 1037 |
+
thr = 0.25 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.25
|
| 1038 |
|
| 1039 |
if is_model_available():
|
| 1040 |
try:
|
| 1041 |
+
dl_mask, dl_score = predict_change_mask(img1, img2, threshold=thr)
|
| 1042 |
dl_method = "adaptformer"
|
| 1043 |
except Exception:
|
| 1044 |
pass
|
| 1045 |
|
| 1046 |
if dl_method == "none":
|
| 1047 |
try:
|
| 1048 |
+
from .cd_models.change_model import is_siamese_available, predict_siamese
|
| 1049 |
+
if is_siamese_available():
|
| 1050 |
+
dl_mask, dl_score = predict_siamese(img1, img2, threshold=thr)
|
| 1051 |
dl_method = "siamese_unet"
|
| 1052 |
except Exception:
|
| 1053 |
pass
|
| 1054 |
|
| 1055 |
+
classical_mask = _multiscale_classical(
|
| 1056 |
+
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 1057 |
+
conf_map = _build_confidence_map_from_channels(
|
| 1058 |
+
img1, img2, dl_score=dl_score if dl_method != "none" else None)
|
| 1059 |
|
| 1060 |
+
dl_w = 0.7 if dl_method != "none" else 0.0
|
| 1061 |
+
cl_w = 1.0 - dl_w
|
| 1062 |
+
fused = dl_w * dl_mask.astype(np.float32) + cl_w * classical_mask.astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
|
| 1064 |
+
if conf_map is not None:
|
| 1065 |
+
conf_boost = np.clip(conf_map * 1.5, 0, 1)
|
| 1066 |
+
fused = fused * (0.6 + 0.4 * conf_boost)
|
| 1067 |
+
|
| 1068 |
+
fused_thr = max(80, int(128 - (sensitivity - 0.5) * 60))
|
| 1069 |
+
_, final_mask = cv2.threshold(fused.astype(np.uint8), fused_thr, 255, cv2.THRESH_BINARY)
|
| 1070 |
+
final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
|
| 1071 |
+
|
| 1072 |
+
debug = {
|
| 1073 |
+
"method": f"Hybrid AI ({dl_method} + multi-scale classical)",
|
| 1074 |
+
"dl_method": dl_method,
|
| 1075 |
+
"threshold_used": fused_thr,
|
| 1076 |
+
"sensitivity": float(sensitivity),
|
| 1077 |
+
"dl_changed_px": int(np.sum(dl_mask > 127)),
|
| 1078 |
+
"classical_changed_px": int(np.sum(classical_mask > 127)),
|
| 1079 |
+
"final_changed_px": int(np.sum(final_mask > 127)),
|
| 1080 |
+
}
|
| 1081 |
+
return final_mask, debug
|
| 1082 |
|
| 1083 |
|
| 1084 |
ALIGNMENT_WARNING_MSG = (
|
|
|
|
| 1134 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 1135 |
|
| 1136 |
# 7. Component-level filtering: remove tiny survivors and elongated noise
|
| 1137 |
+
min_component_px = max(80, int(h * w * 0.000035))
|
| 1138 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
|
| 1139 |
clean = np.zeros_like(filled)
|
| 1140 |
for i in range(1, num_labels):
|
|
|
|
| 1505 |
|
| 1506 |
# ---- Water Body Change ----
|
| 1507 |
water = 0.0
|
| 1508 |
+
if diff and _structural_evidence(diff, feat_a) >= 0.25:
|
| 1509 |
+
water = 0.0 # structural change — skip water scoring entirely
|
| 1510 |
+
else:
|
| 1511 |
+
if feat_a["blue_ratio"] > 0.38:
|
| 1512 |
+
water += 0.22
|
| 1513 |
+
if feat_a["texture_std"] < 26:
|
| 1514 |
+
water += 0.18
|
| 1515 |
+
if feat_a["edge_density"] < 28:
|
| 1516 |
+
water += 0.16
|
| 1517 |
+
if 95 <= feat_a["hue"] <= 130:
|
| 1518 |
+
water += 0.18
|
| 1519 |
+
if feat_a["lbp_variance"] < 0.045:
|
| 1520 |
+
water += 0.14
|
| 1521 |
+
if feat_a["glcm_contrast"] < 450:
|
| 1522 |
+
water += 0.08
|
| 1523 |
+
if area > 1200:
|
| 1524 |
+
water += 0.04
|
| 1525 |
scores["Water Body Change"] = water
|
| 1526 |
|
| 1527 |
# ---- Vegetation Change ----
|
|
|
|
| 1801 |
soil += 0.10
|
| 1802 |
scores["Bare Land/Soil Change"] = soil
|
| 1803 |
|
| 1804 |
+
best, conf = _resolve_classification(scores, diff, feat_a)
|
|
|
|
| 1805 |
|
| 1806 |
+
if conf < 0.28:
|
| 1807 |
return "Unclassified", conf
|
| 1808 |
return best, min(conf, 1.0)
|
| 1809 |
|
|
|
|
| 2493 |
# - keeps sensitivity on smaller images
|
| 2494 |
# - suppresses speckle noise on larger images
|
| 2495 |
if min_area is None:
|
| 2496 |
+
min_area = int(max(250, min(1000, img_area * 0.00009)))
|
| 2497 |
|
| 2498 |
for i in range(1, num_labels):
|
| 2499 |
raw_area = stats[i, cv2.CC_STAT_AREA]
|
|
|
|
| 2503 |
x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
|
| 2504 |
|
| 2505 |
# Reject very sparse regions (bbox is mostly empty)
|
| 2506 |
+
if fill_ratio < 0.15:
|
| 2507 |
continue
|
| 2508 |
|
| 2509 |
# Keep large real changes; only suppress near-full-frame artifacts.
|
|
|
|
| 2522 |
image, (x, y, w, h), before_region=before_img)
|
| 2523 |
|
| 2524 |
if object_type is None:
|
| 2525 |
+
# Keep large coherent regions as generic ground change only when well-filled
|
| 2526 |
+
if raw_area >= max(min_area * 2, 900) and fill_ratio >= 0.20:
|
|
|
|
| 2527 |
object_type = "Unclassified Ground Change"
|
| 2528 |
confidence = max(0.2, min(0.5, fill_ratio))
|
| 2529 |
else:
|
| 2530 |
continue
|
| 2531 |
|
| 2532 |
+
if confidence < 0.24 and raw_area < min_area * 3:
|
| 2533 |
+
continue
|
| 2534 |
+
|
| 2535 |
region_id += 1
|
| 2536 |
region = {
|
| 2537 |
"id": region_id,
|
|
|
|
| 2595 |
|
| 2596 |
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
| 2597 |
enable_registration=True, enable_normalization=True,
|
| 2598 |
+
detection_sensitivity=0.5, min_region_area=None,
|
| 2599 |
+
max_size=None):
|
| 2600 |
"""Run full detection pipeline; returns change_mask, result_image, stats, regions."""
|
| 2601 |
+
ms = max_size or get_detection_max_size()
|
| 2602 |
+
before_array = preprocess_image(before_pil, max_size=ms)
|
| 2603 |
+
after_array = preprocess_image(after_pil, max_size=ms)
|
| 2604 |
|
| 2605 |
registration_ok = False
|
| 2606 |
reg_meta = {}
|
app/main.py
CHANGED
|
@@ -81,14 +81,18 @@ setup_dda(app)
|
|
| 81 |
|
| 82 |
@app.get("/health")
|
| 83 |
def health():
|
| 84 |
-
"""
|
| 85 |
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
| 86 |
return {
|
| 87 |
-
"status": "ok",
|
| 88 |
-
"version": "2.3.0-dda" if IS_DDA_MODE else "2.2.
|
| 89 |
"appMode": "dda" if IS_DDA_MODE else "legacy",
|
| 90 |
"spaceId": os.environ.get("SPACE_ID", ""),
|
| 91 |
"server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
|
|
|
|
| 81 |
|
| 82 |
@app.get("/health")
|
| 83 |
def health():
|
| 84 |
+
"""Health check + AdaptFormer model status (HF Spaces + diagnostics)."""
|
| 85 |
from datetime import datetime
|
| 86 |
+
from .model_inference import get_model_status
|
| 87 |
+
|
| 88 |
+
model = get_model_status()
|
| 89 |
return {
|
| 90 |
+
"status": "ok" if model.get("available") else "degraded",
|
| 91 |
+
"version": "2.3.0-dda" if IS_DDA_MODE else "2.2.1",
|
| 92 |
"appMode": "dda" if IS_DDA_MODE else "legacy",
|
| 93 |
"spaceId": os.environ.get("SPACE_ID", ""),
|
| 94 |
"server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
|
| 95 |
+
"adaptFormer": model,
|
| 96 |
}
|
| 97 |
|
| 98 |
|
app/model_inference.py
CHANGED
|
@@ -22,6 +22,7 @@ _MODEL_ID = "deepang/adaptformer-LEVIR-CD"
|
|
| 22 |
_TILE_SIZE = 256 # LEVIR-CD native patch size
|
| 23 |
_AVAILABLE = None
|
| 24 |
_LOAD_FAILED = False
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def _try_import():
|
|
@@ -34,7 +35,7 @@ def _try_import():
|
|
| 34 |
|
| 35 |
|
| 36 |
def _load_model():
|
| 37 |
-
global _MODEL, _PROCESSOR, _DEVICE, _AVAILABLE, _LOAD_FAILED
|
| 38 |
if _MODEL is not None:
|
| 39 |
return _MODEL, _PROCESSOR
|
| 40 |
if _LOAD_FAILED:
|
|
@@ -56,10 +57,12 @@ def _load_model():
|
|
| 56 |
_MODEL.to(_DEVICE)
|
| 57 |
_MODEL.eval()
|
| 58 |
_AVAILABLE = True
|
|
|
|
| 59 |
logger.info("AdaptFormer loaded on %s", _DEVICE)
|
| 60 |
except Exception as exc:
|
| 61 |
_LOAD_FAILED = True
|
| 62 |
_AVAILABLE = False
|
|
|
|
| 63 |
logger.error("AdaptFormer load failed: %s", exc)
|
| 64 |
raise
|
| 65 |
return _MODEL, _PROCESSOR
|
|
@@ -81,15 +84,38 @@ def is_model_available():
|
|
| 81 |
|
| 82 |
def preload_model():
|
| 83 |
"""Warm-load AdaptFormer at app startup (best-effort)."""
|
|
|
|
| 84 |
try:
|
| 85 |
_load_model()
|
| 86 |
logger.info("AdaptFormer preload complete")
|
| 87 |
return True
|
| 88 |
except Exception as exc:
|
|
|
|
| 89 |
logger.warning("AdaptFormer preload skipped: %s", exc)
|
| 90 |
return False
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def predict_change_mask(img1, img2, threshold=0.5):
|
| 94 |
"""
|
| 95 |
Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
|
|
|
|
| 22 |
_TILE_SIZE = 256 # LEVIR-CD native patch size
|
| 23 |
_AVAILABLE = None
|
| 24 |
_LOAD_FAILED = False
|
| 25 |
+
_LOAD_ERROR: str | None = None
|
| 26 |
|
| 27 |
|
| 28 |
def _try_import():
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def _load_model():
|
| 38 |
+
global _MODEL, _PROCESSOR, _DEVICE, _AVAILABLE, _LOAD_FAILED, _LOAD_ERROR
|
| 39 |
if _MODEL is not None:
|
| 40 |
return _MODEL, _PROCESSOR
|
| 41 |
if _LOAD_FAILED:
|
|
|
|
| 57 |
_MODEL.to(_DEVICE)
|
| 58 |
_MODEL.eval()
|
| 59 |
_AVAILABLE = True
|
| 60 |
+
_LOAD_ERROR = None
|
| 61 |
logger.info("AdaptFormer loaded on %s", _DEVICE)
|
| 62 |
except Exception as exc:
|
| 63 |
_LOAD_FAILED = True
|
| 64 |
_AVAILABLE = False
|
| 65 |
+
_LOAD_ERROR = str(exc)
|
| 66 |
logger.error("AdaptFormer load failed: %s", exc)
|
| 67 |
raise
|
| 68 |
return _MODEL, _PROCESSOR
|
|
|
|
| 84 |
|
| 85 |
def preload_model():
|
| 86 |
"""Warm-load AdaptFormer at app startup (best-effort)."""
|
| 87 |
+
global _LOAD_ERROR
|
| 88 |
try:
|
| 89 |
_load_model()
|
| 90 |
logger.info("AdaptFormer preload complete")
|
| 91 |
return True
|
| 92 |
except Exception as exc:
|
| 93 |
+
_LOAD_ERROR = str(exc)
|
| 94 |
logger.warning("AdaptFormer preload skipped: %s", exc)
|
| 95 |
return False
|
| 96 |
|
| 97 |
|
| 98 |
+
def get_model_status() -> dict:
|
| 99 |
+
"""Status for /health — shows whether AI detection or classical fallback is active."""
|
| 100 |
+
if _AVAILABLE is True:
|
| 101 |
+
mode = "adaptformer_smart_union"
|
| 102 |
+
available = True
|
| 103 |
+
elif _LOAD_FAILED:
|
| 104 |
+
mode = "classical_fallback"
|
| 105 |
+
available = False
|
| 106 |
+
else:
|
| 107 |
+
available = is_model_available()
|
| 108 |
+
mode = "adaptformer_smart_union" if available else "classical_fallback"
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"modelId": _MODEL_ID,
|
| 112 |
+
"available": available,
|
| 113 |
+
"detectionMode": mode,
|
| 114 |
+
"device": str(_DEVICE) if _DEVICE is not None else None,
|
| 115 |
+
"error": _LOAD_ERROR,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
def predict_change_mask(img1, img2, threshold=0.5):
|
| 120 |
"""
|
| 121 |
Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
|
requirements.txt
CHANGED
|
@@ -11,9 +11,10 @@ python-jose[cryptography]>=3.3.0
|
|
| 11 |
passlib[bcrypt]>=1.7.4
|
| 12 |
bcrypt==4.0.1
|
| 13 |
pillow>=10.0.0
|
| 14 |
-
numpy>=1.
|
| 15 |
opencv-python-headless>=4.8.0
|
| 16 |
scikit-learn>=1.3.0
|
| 17 |
requests>=2.28.0
|
| 18 |
-
|
|
|
|
| 19 |
pyproj>=3.6.0
|
|
|
|
| 11 |
passlib[bcrypt]>=1.7.4
|
| 12 |
bcrypt==4.0.1
|
| 13 |
pillow>=10.0.0
|
| 14 |
+
numpy>=1.26,<2
|
| 15 |
opencv-python-headless>=4.8.0
|
| 16 |
scikit-learn>=1.3.0
|
| 17 |
requests>=2.28.0
|
| 18 |
+
protobuf>=5.28.0,<6
|
| 19 |
+
rasterio>=1.3.0,<1.5
|
| 20 |
pyproj>=3.6.0
|
static/css/dda.css
CHANGED
|
@@ -317,3 +317,33 @@
|
|
| 317 |
background: linear-gradient(90deg, var(--grad-start), var(--grad-end, #10b981));
|
| 318 |
transition: width 0.15s ease;
|
| 319 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
background: linear-gradient(90deg, var(--grad-start), var(--grad-end, #10b981));
|
| 318 |
transition: width 0.15s ease;
|
| 319 |
}
|
| 320 |
+
|
| 321 |
+
.dda-hierarchy-wrap {
|
| 322 |
+
margin-top: 1rem;
|
| 323 |
+
padding-top: 0.75rem;
|
| 324 |
+
border-top: 1px solid var(--border);
|
| 325 |
+
}
|
| 326 |
+
.dda-hierarchy-title {
|
| 327 |
+
font-size: 0.75rem;
|
| 328 |
+
text-transform: uppercase;
|
| 329 |
+
letter-spacing: 0.04em;
|
| 330 |
+
margin: 0 0 0.5rem;
|
| 331 |
+
color: var(--text-muted, #888);
|
| 332 |
+
}
|
| 333 |
+
.dda-hierarchy-zone { margin-bottom: 0.35rem; }
|
| 334 |
+
.dda-hierarchy-zone summary {
|
| 335 |
+
cursor: pointer;
|
| 336 |
+
font-size: 0.85rem;
|
| 337 |
+
font-weight: 600;
|
| 338 |
+
padding: 0.2rem 0;
|
| 339 |
+
}
|
| 340 |
+
.dda-hierarchy-list {
|
| 341 |
+
list-style: none;
|
| 342 |
+
margin: 0.25rem 0 0.5rem 0.75rem;
|
| 343 |
+
padding: 0;
|
| 344 |
+
font-size: 0.8rem;
|
| 345 |
+
}
|
| 346 |
+
.dda-hierarchy-village { padding: 0.15rem 0; }
|
| 347 |
+
|
| 348 |
+
.regions-table tr.region-selected { background: rgba(16, 185, 129, 0.12); }
|
| 349 |
+
.regions-table tr { cursor: pointer; }
|
static/js/dda/app.js
CHANGED
|
@@ -121,11 +121,41 @@ async function initDda() {
|
|
| 121 |
localYears = yearsData.years || [];
|
| 122 |
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
| 123 |
await loadLibraryImages();
|
|
|
|
| 124 |
} catch (err) {
|
| 125 |
showDdaError(err.message || 'Failed to load library');
|
| 126 |
}
|
| 127 |
}
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
async function loadLibraryImages() {
|
| 130 |
const grid = document.getElementById('lib-grid');
|
| 131 |
const title = document.getElementById('lib-grid-title');
|
|
|
|
| 121 |
localYears = yearsData.years || [];
|
| 122 |
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
| 123 |
await loadLibraryImages();
|
| 124 |
+
await loadHierarchyTree();
|
| 125 |
} catch (err) {
|
| 126 |
showDdaError(err.message || 'Failed to load library');
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
| 130 |
+
async function loadHierarchyTree() {
|
| 131 |
+
const el = document.getElementById('lib-hierarchy');
|
| 132 |
+
if (!el) return;
|
| 133 |
+
try {
|
| 134 |
+
const data = await ddaApi('GET', '/api/dda/hierarchy');
|
| 135 |
+
const zones = data.zones || [];
|
| 136 |
+
if (!zones.length) {
|
| 137 |
+
el.innerHTML = '<p class="dim">No zones seeded.</p>';
|
| 138 |
+
return;
|
| 139 |
+
}
|
| 140 |
+
el.innerHTML = zones.map((z) => {
|
| 141 |
+
const villages = z.villages || [];
|
| 142 |
+
const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
|
| 143 |
+
const villageItems = villages.map((v) =>
|
| 144 |
+
`<li class="dda-hierarchy-village">${v.name}${v.imageCount ? ` <span class="dim">(${v.imageCount})</span>` : ''}</li>`
|
| 145 |
+
).join('');
|
| 146 |
+
return `
|
| 147 |
+
<details class="dda-hierarchy-zone" open>
|
| 148 |
+
<summary>${z.name}${zoneCount ? ` <span class="dim">(${zoneCount})</span>` : ''}</summary>
|
| 149 |
+
<ul class="dda-hierarchy-list">${villageItems || '<li class="dim">No villages</li>'}</ul>
|
| 150 |
+
</details>`;
|
| 151 |
+
}).join('');
|
| 152 |
+
} catch (_) {
|
| 153 |
+
el.innerHTML = '<p class="dim">Zone tree unavailable.</p>';
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
window.loadHierarchyTree = loadHierarchyTree;
|
| 158 |
+
|
| 159 |
async function loadLibraryImages() {
|
| 160 |
const grid = document.getElementById('lib-grid');
|
| 161 |
const title = document.getElementById('lib-grid-title');
|
static/js/dda/compare.js
CHANGED
|
@@ -291,6 +291,22 @@ function showDetectResult(data) {
|
|
| 291 |
else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.');
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
async function pollJobUntilDone(jobId, loadingEl) {
|
| 295 |
const maxAttempts = 600;
|
| 296 |
for (let i = 0; i < maxAttempts; i++) {
|
|
@@ -330,7 +346,7 @@ async function runLibraryDetection() {
|
|
| 330 |
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
| 331 |
|
| 332 |
try {
|
| 333 |
-
const data = await
|
| 334 |
showDetectResult(data);
|
| 335 |
if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
|
| 336 |
if (typeof loadReportsList === 'function') loadReportsList();
|
|
|
|
| 291 |
else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.');
|
| 292 |
}
|
| 293 |
|
| 294 |
+
async function runDetectionWithFallback(form, loadingEl) {
|
| 295 |
+
const hosted = window.ddaState?.localCfg?.isHosted;
|
| 296 |
+
if (hosted) {
|
| 297 |
+
loadingEl.textContent = 'Queuing detection job…';
|
| 298 |
+
try {
|
| 299 |
+
const queued = await ddaApi('POST', '/api/dda/jobs', { body: form });
|
| 300 |
+
return await pollJobUntilDone(queued.jobId, loadingEl);
|
| 301 |
+
} catch (err) {
|
| 302 |
+
const msg = String(err.message || '');
|
| 303 |
+
if (!msg.includes('Not Found') && !msg.includes('404')) throw err;
|
| 304 |
+
loadingEl.textContent = 'Running detection (sync fallback)…';
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
return ddaApi('POST', '/api/dda/detect/from-library', { body: form });
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
async function pollJobUntilDone(jobId, loadingEl) {
|
| 311 |
const maxAttempts = 600;
|
| 312 |
for (let i = 0; i < maxAttempts; i++) {
|
|
|
|
| 346 |
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
| 347 |
|
| 348 |
try {
|
| 349 |
+
const data = await runDetectionWithFallback(form, loading);
|
| 350 |
showDetectResult(data);
|
| 351 |
if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
|
| 352 |
if (typeof loadReportsList === 'function') loadReportsList();
|
static/js/dda/result.js
CHANGED
|
@@ -99,7 +99,7 @@ function showDdaResult(data) {
|
|
| 99 |
<td>${subType}</td>
|
| 100 |
<td><span class="severity-badge ${severity}">${severity}</span></td>
|
| 101 |
<td>${(r.confidence * 100).toFixed(1)}%</td>
|
| 102 |
-
<td>${r.area.toLocaleString()}</td>
|
| 103 |
<td>(${r.center.x}, ${r.center.y})</td>
|
| 104 |
<td>${stories}</td>
|
| 105 |
<td>${height}</td>
|
|
@@ -156,38 +156,63 @@ function renderDdaRegionPage() {
|
|
| 156 |
function setupDdaRegionHover(tbody, regions) {
|
| 157 |
const overlay = document.getElementById('region-highlight-overlay');
|
| 158 |
if (!overlay) return;
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
tbody.querySelectorAll('tr[data-region-id]').forEach((tr) => {
|
|
|
|
|
|
|
| 161 |
tr.addEventListener('mouseenter', () => {
|
| 162 |
const id = parseInt(tr.dataset.regionId, 10);
|
| 163 |
const r = regions.find((x) => x.id === id);
|
| 164 |
-
if (!r || !r.bbox) return;
|
| 165 |
tbody.querySelectorAll('tr').forEach((row) => row.classList.remove('region-hover'));
|
| 166 |
tr.classList.add('region-hover');
|
| 167 |
-
|
| 168 |
-
box.className = 'highlight-box';
|
| 169 |
-
const imgEl = document.getElementById('compare-after-img');
|
| 170 |
-
const slider = document.getElementById('compare-slider');
|
| 171 |
-
if (!imgEl || !slider || !imgEl.naturalWidth) return;
|
| 172 |
-
const rw = slider.offsetWidth;
|
| 173 |
-
const rh = slider.offsetHeight;
|
| 174 |
-
const imgW = imgEl.naturalWidth || 1;
|
| 175 |
-
const imgH = imgEl.naturalHeight || 1;
|
| 176 |
-
const scale = Math.min(rw / imgW, rh / imgH);
|
| 177 |
-
const drawW = imgW * scale;
|
| 178 |
-
const drawH = imgH * scale;
|
| 179 |
-
const offsetX = (rw - drawW) / 2;
|
| 180 |
-
const offsetY = (rh - drawH) / 2;
|
| 181 |
-
box.style.left = (offsetX + r.bbox.x * scale) + 'px';
|
| 182 |
-
box.style.top = (offsetY + r.bbox.y * scale) + 'px';
|
| 183 |
-
box.style.width = (r.bbox.w * scale) + 'px';
|
| 184 |
-
box.style.height = (r.bbox.h * scale) + 'px';
|
| 185 |
-
overlay.appendChild(box);
|
| 186 |
});
|
| 187 |
tr.addEventListener('mouseleave', () => {
|
| 188 |
tr.classList.remove('region-hover');
|
| 189 |
overlay.innerHTML = '';
|
| 190 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
});
|
| 192 |
}
|
| 193 |
|
|
|
|
| 99 |
<td>${subType}</td>
|
| 100 |
<td><span class="severity-badge ${severity}">${severity}</span></td>
|
| 101 |
<td>${(r.confidence * 100).toFixed(1)}%</td>
|
| 102 |
+
<td>${r.areaSqM != null ? r.areaSqM.toLocaleString() + ' m²' : r.area.toLocaleString()}</td>
|
| 103 |
<td>(${r.center.x}, ${r.center.y})</td>
|
| 104 |
<td>${stories}</td>
|
| 105 |
<td>${height}</td>
|
|
|
|
| 156 |
function setupDdaRegionHover(tbody, regions) {
|
| 157 |
const overlay = document.getElementById('region-highlight-overlay');
|
| 158 |
if (!overlay) return;
|
| 159 |
+
|
| 160 |
+
function showRegionHighlight(r, zoomTo) {
|
| 161 |
+
if (!r || !r.bbox) return;
|
| 162 |
+
overlay.innerHTML = '';
|
| 163 |
+
const box = document.createElement('div');
|
| 164 |
+
box.className = 'highlight-box';
|
| 165 |
+
const imgEl = document.getElementById('compare-after-img');
|
| 166 |
+
const slider = document.getElementById('compare-slider');
|
| 167 |
+
const wrapper = document.getElementById('zoom-wrapper');
|
| 168 |
+
if (!imgEl || !slider || !imgEl.naturalWidth) return;
|
| 169 |
+
const rw = slider.offsetWidth;
|
| 170 |
+
const rh = slider.offsetHeight;
|
| 171 |
+
const imgW = imgEl.naturalWidth || 1;
|
| 172 |
+
const imgH = imgEl.naturalHeight || 1;
|
| 173 |
+
const scale = Math.min(rw / imgW, rh / imgH);
|
| 174 |
+
const drawW = imgW * scale;
|
| 175 |
+
const drawH = imgH * scale;
|
| 176 |
+
const offsetX = (rw - drawW) / 2;
|
| 177 |
+
const offsetY = (rh - drawH) / 2;
|
| 178 |
+
box.style.left = (offsetX + r.bbox.x * scale) + 'px';
|
| 179 |
+
box.style.top = (offsetY + r.bbox.y * scale) + 'px';
|
| 180 |
+
box.style.width = (r.bbox.w * scale) + 'px';
|
| 181 |
+
box.style.height = (r.bbox.h * scale) + 'px';
|
| 182 |
+
overlay.appendChild(box);
|
| 183 |
+
|
| 184 |
+
if (zoomTo && wrapper && r.bbox.w > 0 && r.bbox.h > 0) {
|
| 185 |
+
const cx = r.bbox.x + r.bbox.w / 2;
|
| 186 |
+
const cy = r.bbox.y + r.bbox.h / 2;
|
| 187 |
+
const targetZoom = Math.min(DDA_ZOOM_MAX, Math.max(1.5, Math.min(drawW / (r.bbox.w * scale * 2.5), drawH / (r.bbox.h * scale * 2.5))));
|
| 188 |
+
ddaZoom = targetZoom;
|
| 189 |
+
applyDdaZoom();
|
| 190 |
+
wrapper.scrollLeft = Math.max(0, (offsetX + cx * scale) * ddaZoom - wrapper.clientWidth / 2);
|
| 191 |
+
wrapper.scrollTop = Math.max(0, (offsetY + cy * scale) * ddaZoom - wrapper.clientHeight / 2);
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
tbody.querySelectorAll('tr[data-region-id]').forEach((tr) => {
|
| 196 |
+
tr.style.cursor = 'pointer';
|
| 197 |
+
tr.title = 'Click to locate on map';
|
| 198 |
tr.addEventListener('mouseenter', () => {
|
| 199 |
const id = parseInt(tr.dataset.regionId, 10);
|
| 200 |
const r = regions.find((x) => x.id === id);
|
|
|
|
| 201 |
tbody.querySelectorAll('tr').forEach((row) => row.classList.remove('region-hover'));
|
| 202 |
tr.classList.add('region-hover');
|
| 203 |
+
showRegionHighlight(r, false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
});
|
| 205 |
tr.addEventListener('mouseleave', () => {
|
| 206 |
tr.classList.remove('region-hover');
|
| 207 |
overlay.innerHTML = '';
|
| 208 |
});
|
| 209 |
+
tr.addEventListener('click', () => {
|
| 210 |
+
const id = parseInt(tr.dataset.regionId, 10);
|
| 211 |
+
const r = regions.find((x) => x.id === id);
|
| 212 |
+
tbody.querySelectorAll('tr').forEach((row) => row.classList.remove('region-selected'));
|
| 213 |
+
tr.classList.add('region-selected');
|
| 214 |
+
showRegionHighlight(r, true);
|
| 215 |
+
});
|
| 216 |
});
|
| 217 |
}
|
| 218 |
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -36,6 +36,10 @@
|
|
| 36 |
<div id="lib-folder-path" class="dda-folder-path dim"></div>
|
| 37 |
<input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter years…" />
|
| 38 |
<div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</aside>
|
| 40 |
<main class="dda-main">
|
| 41 |
<div class="card dda-instructions">
|
|
@@ -228,10 +232,10 @@
|
|
| 228 |
</div>
|
| 229 |
</div>
|
| 230 |
|
| 231 |
-
<script src="/static/js/dda/app.js?v=
|
| 232 |
-
<script src="/static/js/dda/library.js?v=
|
| 233 |
-
<script src="/static/js/dda/result.js?v=
|
| 234 |
-
<script src="/static/js/dda/compare.js?v=
|
| 235 |
-
<script src="/static/js/dda/reports.js?v=
|
| 236 |
</body>
|
| 237 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=8" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 36 |
<div id="lib-folder-path" class="dda-folder-path dim"></div>
|
| 37 |
<input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter years…" />
|
| 38 |
<div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
| 39 |
+
<div class="dda-hierarchy-wrap">
|
| 40 |
+
<h4 class="dda-hierarchy-title">DDA zones</h4>
|
| 41 |
+
<div id="lib-hierarchy" class="dda-hierarchy"><p class="dim">Loading…</p></div>
|
| 42 |
+
</div>
|
| 43 |
</aside>
|
| 44 |
<main class="dda-main">
|
| 45 |
<div class="card dda-instructions">
|
|
|
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
|
| 235 |
+
<script src="/static/js/dda/app.js?v=10"></script>
|
| 236 |
+
<script src="/static/js/dda/library.js?v=6"></script>
|
| 237 |
+
<script src="/static/js/dda/result.js?v=4"></script>
|
| 238 |
+
<script src="/static/js/dda/compare.js?v=7"></script>
|
| 239 |
+
<script src="/static/js/dda/reports.js?v=2"></script>
|
| 240 |
</body>
|
| 241 |
</html>
|