coderuday21 Cursor commited on
Commit
66006d5
·
1 Parent(s): deaa6d8

Deploy DDA jobs API, hierarchy panel, and region locate on dev.

Browse files

Async 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 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=32
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 (local folder library + legacy upload API)")
 
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=False)
81
- comparison_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=False)
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=1600):
42
- """Preprocess image: convert to RGB, limit size, light Gaussian denoise."""
 
 
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
- img_array = cv2.GaussianBlur(img_array, (5, 5), 0)
 
 
53
  gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
54
  lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
55
- if lap_var < 80.0:
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
- q = float(np.clip(0.96 - (sens - 0.5) * 0.04, 0.92, 0.98))
 
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
- """AdaptFormer + confidence-gated classical fusion (no blind union)."""
 
 
801
  from .model_inference import is_model_available, predict_change_mask
802
 
 
803
  dl_score = None
804
  model_ok = False
805
- T_dl = 0.40 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.25
806
 
807
  if is_model_available():
808
  try:
809
- _, dl_score = predict_change_mask(img1, img2, threshold=2.0)
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, _ = _compute_classical_score_map(
815
- img1, img2, registration_ok=registration_ok)
816
 
817
  if model_ok and dl_score is not None:
818
- combined, _, fuse_debug = fuse_dl_and_classical(
819
- dl_score, classical_score, img1, img2, sensitivity=sensitivity)
 
 
 
820
  debug = {
821
- "method": "AI-Based Deep Learning (AdaptFormer + gated fusion)",
822
  "model": "adaptformer-levir-cd",
823
- "threshold_used": int(T_dl * 255),
 
824
  "sensitivity": float(sensitivity),
825
- **fuse_debug,
 
 
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 = 110
856
  sens = float(np.clip(sensitivity, 0.0, 1.0))
857
- hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36), 70, 160))
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: same confidence-gated fusion as default AI path."""
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
- dl_score = None
 
912
  dl_method = "none"
 
913
 
914
  if is_model_available():
915
  try:
916
- _, dl_score = predict_change_mask(img1, img2, threshold=2.0)
917
  dl_method = "adaptformer"
918
  except Exception:
919
  pass
920
 
921
  if dl_method == "none":
922
  try:
923
- from .cd_models.change_model import has_siamese_weights, predict_siamese
924
- if has_siamese_weights():
925
- _, dl_score = predict_siamese(img1, img2, threshold=2.0)
926
  dl_method = "siamese_unet"
927
  except Exception:
928
  pass
929
 
930
- classical_score, _ = _compute_classical_score_map(
931
- img1, img2, registration_ok=registration_ok)
 
 
932
 
933
- if dl_method != "none" and dl_score is not None:
934
- final_mask, _, fuse_debug = fuse_dl_and_classical(
935
- dl_score, classical_score, img1, img2, sensitivity=sensitivity)
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
- mask, _, core_debug = _ai_fusion_core(
945
- img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
946
- return mask, {"method": "Hybrid AI (classical fallback)", "core": core_debug}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(200, int(h * w * 0.00003))
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["blue_ratio"] > 0.36:
1374
- water += 0.22
1375
- if feat_a["texture_std"] < 28:
1376
- water += 0.18
1377
- if feat_a["edge_density"] < 35:
1378
- water += 0.14
1379
- if 90 <= feat_a["hue"] <= 135:
1380
- water += 0.18
1381
- if feat_a["lbp_variance"] < 0.05:
1382
- water += 0.14
1383
- if feat_a["glcm_contrast"] < 500:
1384
- water += 0.10
1385
- if area > 800:
1386
- water += 0.04
 
 
 
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 = max(scores, key=scores.get)
1667
- conf = scores[best]
1668
 
1669
- if conf < 0.22:
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(200, min(1000, img_area * 0.00008)))
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.12:
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
- # Do not silently drop large coherent regions; keep them as generic
2389
- # ground-change candidates so key changes are still surfaced.
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
- before_array = preprocess_image(before_pil)
2462
- after_array = preprocess_image(after_pil)
 
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
- """Lightweight health check so Hugging Face can mark the Space as running quickly."""
85
  from datetime import datetime
 
 
 
86
  return {
87
- "status": "ok",
88
- "version": "2.3.0-dda" if IS_DDA_MODE else "2.2.0",
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.24.0
15
  opencv-python-headless>=4.8.0
16
  scikit-learn>=1.3.0
17
  requests>=2.28.0
18
- rasterio>=1.3.0
 
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 ddaApi('POST', '/api/dda/detect/from-library', { body: form });
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
- overlay.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const box = document.createElement('div');
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=7" />
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=9"></script>
232
- <script src="/static/js/dda/library.js?v=5"></script>
233
- <script src="/static/js/dda/result.js?v=3"></script>
234
- <script src="/static/js/dda/compare.js?v=6"></script>
235
- <script src="/static/js/dda/reports.js?v=1"></script>
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>