coderuday21 Cursor commited on
Commit
41d16b3
·
1 Parent(s): c506ed2

Phase 0 DDA: APP_MODE, image library API, GeoTIFF ingest, dev UI

Browse files
DEPLOYMENT.md CHANGED
@@ -35,9 +35,11 @@ git remote set-url hf-dev https://huggingface.co/spaces/coderuday21/satdetect-de
35
  Push the current development code to the new Space:
36
 
37
  ```powershell
38
- git push hf-dev master:main
39
  ```
40
 
 
 
41
  Or run the helper script:
42
 
43
  ```powershell
@@ -99,18 +101,27 @@ Helper:
99
 
100
  ---
101
 
102
- ## GitHub (optional mirror)
 
 
 
 
 
 
 
 
 
103
 
104
- GitHub tracks `master` only:
105
 
106
  ```powershell
107
- git push origin master
108
  ```
109
 
110
- You can add a `production` branch on GitHub too:
111
 
112
  ```powershell
113
- git push origin production
114
  ```
115
 
116
  ---
 
35
  Push the current development code to the new Space:
36
 
37
  ```powershell
38
+ git push hf-dev master:main --force
39
  ```
40
 
41
+ > **First push only:** Hugging Face creates a starter README commit when you create the Space. Use `--force` once to replace it with your app. Later pushes can omit `--force`.
42
+
43
  Or run the helper script:
44
 
45
  ```powershell
 
101
 
102
  ---
103
 
104
+ ## GitHub mirror
105
+
106
+ **Repo:** https://github.com/Uday-at-Vedang/DDA.ChangeDetection
107
+ **Default branch on GitHub:** `main`
108
+
109
+ Push the full app from local `master`:
110
+
111
+ ```powershell
112
+ git push origin master:main
113
+ ```
114
 
115
+ Push the production branch too:
116
 
117
  ```powershell
118
+ git push origin production:main-production
119
  ```
120
 
121
+ Set upstream (once, after first push):
122
 
123
  ```powershell
124
+ git branch --set-upstream-to=origin/main master
125
  ```
126
 
127
  ---
Dockerfile CHANGED
@@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
10
  libsm6 \
11
  libxext6 \
12
  libxrender1 \
 
 
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
  # Create non-root user (required by Hugging Face Spaces)
@@ -19,8 +21,9 @@ WORKDIR /app
19
 
20
  # Build-time info + cache-bust:
21
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
22
- ARG APP_BUILD=23
23
  ENV APP_BUILD=${APP_BUILD}
 
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
26
  # Install Python dependencies
 
10
  libsm6 \
11
  libxext6 \
12
  libxrender1 \
13
+ gdal-bin \
14
+ libgdal-dev \
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
  # Create non-root user (required by Hugging Face Spaces)
 
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
25
  ENV APP_BUILD=${APP_BUILD}
26
+ ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
28
 
29
  # Install Python dependencies
app/dda/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """DDA change detection module (dev Space / APP_MODE=dda)."""
app/dda/bootstrap.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import FastAPI
4
+ from sqlalchemy import text as sa_text
5
+
6
+ from ..database import engine
7
+ from .config import IS_DDA_MODE, ensure_library_dirs
8
+ from .library_routes import router as library_router
9
+ from .seed import seed_delhi_hierarchy
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def init_dda_database():
15
+ """Run DDA-specific startup tasks (dirs, seed, migrations)."""
16
+ if not IS_DDA_MODE:
17
+ return
18
+ ensure_library_dirs()
19
+ try:
20
+ with engine.connect() as conn:
21
+ try:
22
+ conn.execute(sa_text("ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'"))
23
+ conn.commit()
24
+ except Exception:
25
+ conn.rollback()
26
+ except Exception as exc:
27
+ logger.warning("DDA user role migration skipped: %s", exc)
28
+
29
+ from ..database import SessionLocal
30
+ db = SessionLocal()
31
+ try:
32
+ seed_delhi_hierarchy(db)
33
+ finally:
34
+ db.close()
35
+
36
+
37
+ def setup_dda(app: FastAPI) -> None:
38
+ if not IS_DDA_MODE:
39
+ logger.info("APP_MODE=legacy — DDA routes disabled")
40
+ return
41
+ app.include_router(library_router, prefix="/api/dda", tags=["dda"])
42
+ logger.info("APP_MODE=dda — DDA library routes enabled")
app/dda/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from ..database import DATA_DIR
5
+
6
+ APP_MODE = os.environ.get("APP_MODE", "legacy").strip().lower()
7
+ IS_DDA_MODE = APP_MODE == "dda"
8
+
9
+ LIBRARY_DIR = DATA_DIR / "library"
10
+ THUMBS_DIR = LIBRARY_DIR / "thumbs"
11
+ PREVIEWS_DIR = LIBRARY_DIR / "previews"
12
+
13
+ # GeoTIFF upload limit (DDA responsible for suitable resolution per SOW)
14
+ MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "500")) * 1024 * 1024
15
+
16
+ ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
17
+
18
+
19
+ def ensure_library_dirs() -> None:
20
+ for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR):
21
+ try:
22
+ d.mkdir(parents=True, exist_ok=True)
23
+ except OSError:
24
+ pass
25
+
26
+
27
+ def geotiff_io_available() -> bool:
28
+ try:
29
+ import rasterio # noqa: F401
30
+ return True
31
+ except ImportError:
32
+ return False
app/dda/geotiff_io.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GeoTIFF ingest and preview generation (FR-02). Rasterio optional at import time."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional, Tuple
9
+
10
+ from PIL import Image
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class IngestResult:
17
+ width: int
18
+ height: int
19
+ has_georef: bool
20
+ crs: str
21
+ bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north
22
+ format: str
23
+
24
+
25
+ def _read_with_rasterio(path: Path) -> IngestResult:
26
+ import rasterio
27
+ from rasterio.warp import transform_bounds
28
+
29
+ with rasterio.open(path) as src:
30
+ crs = str(src.crs) if src.crs else ""
31
+ has_georef = src.crs is not None
32
+ bounds = None
33
+ if has_georef and src.bounds:
34
+ try:
35
+ w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
36
+ bounds = (float(w), float(s), float(e), float(n))
37
+ except Exception as exc:
38
+ logger.warning("Could not transform bounds to WGS84: %s", exc)
39
+ return IngestResult(
40
+ width=int(src.width),
41
+ height=int(src.height),
42
+ has_georef=has_georef,
43
+ crs=crs,
44
+ bounds_wgs84=bounds,
45
+ format="geotiff",
46
+ )
47
+
48
+
49
+ def _read_with_pillow(path: Path) -> IngestResult:
50
+ with Image.open(path) as img:
51
+ w, h = img.size
52
+ ext = path.suffix.lower()
53
+ fmt = "geotiff" if ext in (".tif", ".tiff") else "image"
54
+ return IngestResult(
55
+ width=w,
56
+ height=h,
57
+ has_georef=False,
58
+ crs="",
59
+ bounds_wgs84=None,
60
+ format=fmt,
61
+ )
62
+
63
+
64
+ def inspect_image(path: Path) -> IngestResult:
65
+ ext = path.suffix.lower()
66
+ if ext in (".tif", ".tiff"):
67
+ try:
68
+ return _read_with_rasterio(path)
69
+ except ImportError:
70
+ logger.warning("rasterio not installed — GeoTIFF metadata limited")
71
+ except Exception as exc:
72
+ logger.warning("rasterio read failed (%s), falling back to Pillow", exc)
73
+ return _read_with_pillow(path)
74
+
75
+
76
+ def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
77
+ """Create RGB thumbnail/preview from GeoTIFF or raster image."""
78
+ ext = src_path.suffix.lower()
79
+ if ext in (".tif", ".tiff"):
80
+ try:
81
+ import numpy as np
82
+ import rasterio
83
+
84
+ with rasterio.open(src_path) as src:
85
+ count = min(3, src.count)
86
+ data = src.read(indexes=list(range(1, count + 1)))
87
+ if count == 1:
88
+ rgb = np.stack([data[0], data[0], data[0]])
89
+ else:
90
+ rgb = data[:3]
91
+ rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
92
+ if rgb.max() > 255 or rgb.min() < 0:
93
+ lo, hi = np.percentile(rgb, (2, 98))
94
+ rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
95
+ img = Image.fromarray(rgb.astype("uint8"), mode="RGB")
96
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
97
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
98
+ img.save(dest_path, format="PNG")
99
+ return
100
+ except Exception as exc:
101
+ logger.warning("GeoTIFF preview via rasterio failed: %s", exc)
102
+
103
+ with Image.open(src_path) as img:
104
+ img = img.convert("RGB")
105
+ img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
106
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
107
+ img.save(dest_path, format="PNG")
108
+
109
+
110
+ def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
111
+ if not bounds:
112
+ return ""
113
+ return json.dumps({"west": bounds[0], "south": bounds[1], "east": bounds[2], "north": bounds[3]})
app/dda/library_routes.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import shutil
4
+ import uuid
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
10
+ from fastapi.responses import FileResponse
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..auth import get_or_create_guest_user
14
+ from ..database import get_db
15
+ from ..models import User
16
+ from .config import (
17
+ ALLOWED_EXTENSIONS,
18
+ IS_DDA_MODE,
19
+ LIBRARY_DIR,
20
+ MAX_GEOTIFF_BYTES,
21
+ PREVIEWS_DIR,
22
+ THUMBS_DIR,
23
+ ensure_library_dirs,
24
+ geotiff_io_available,
25
+ )
26
+ from .geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png
27
+ from .models import DdaVillage, DdaZone, ImageAsset
28
+
29
+ logger = logging.getLogger(__name__)
30
+ router = APIRouter()
31
+
32
+
33
+ def _require_dda():
34
+ if not IS_DDA_MODE:
35
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
36
+
37
+
38
+ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "") -> dict:
39
+ return {
40
+ "id": asset.id,
41
+ "uuid": asset.uuid,
42
+ "zoneId": asset.zone_id,
43
+ "zoneName": zone_name,
44
+ "villageId": asset.village_id,
45
+ "villageName": village_name,
46
+ "areaName": asset.area_name or "",
47
+ "year": asset.year,
48
+ "gridId": asset.grid_id or "",
49
+ "captureDate": asset.capture_date.isoformat() if asset.capture_date else None,
50
+ "source": asset.source,
51
+ "format": asset.format,
52
+ "originalFilename": asset.original_filename,
53
+ "hasGeoref": asset.has_georef,
54
+ "crs": asset.crs or "",
55
+ "bounds": json.loads(asset.bounds_json) if asset.bounds_json else None,
56
+ "width": asset.width,
57
+ "height": asset.height,
58
+ "fileSizeBytes": asset.file_size_bytes,
59
+ "thumbUrl": f"/api/dda/images/{asset.id}/thumb" if asset.thumb_path else None,
60
+ "createdAt": asset.created_at.isoformat() if asset.created_at else None,
61
+ }
62
+
63
+
64
+ @router.get("/config")
65
+ def dda_config():
66
+ _require_dda()
67
+ return {
68
+ "mode": "dda",
69
+ "maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
70
+ "geotiffEnabled": geotiff_io_available(),
71
+ "allowedExtensions": sorted(ALLOWED_EXTENSIONS),
72
+ "hierarchyMode": "admin",
73
+ }
74
+
75
+
76
+ @router.get("/hierarchy")
77
+ def get_hierarchy(db: Session = Depends(get_db)):
78
+ _require_dda()
79
+ zones = db.query(DdaZone).order_by(DdaZone.name).all()
80
+ tree = []
81
+ for zone in zones:
82
+ villages = (
83
+ db.query(DdaVillage)
84
+ .filter(DdaVillage.zone_id == zone.id)
85
+ .order_by(DdaVillage.name)
86
+ .all()
87
+ )
88
+ image_counts = {}
89
+ for v in villages:
90
+ cnt = db.query(ImageAsset).filter(ImageAsset.village_id == v.id).count()
91
+ if cnt:
92
+ image_counts[v.id] = cnt
93
+ tree.append({
94
+ "id": zone.id,
95
+ "name": zone.name,
96
+ "mode": zone.mode,
97
+ "villages": [
98
+ {
99
+ "id": v.id,
100
+ "name": v.name,
101
+ "imageCount": image_counts.get(v.id, 0),
102
+ }
103
+ for v in villages
104
+ ],
105
+ })
106
+ return {"zones": tree}
107
+
108
+
109
+ @router.get("/images")
110
+ def list_images(
111
+ zone_id: Optional[int] = Query(None),
112
+ village_id: Optional[int] = Query(None),
113
+ year: Optional[int] = Query(None),
114
+ q: Optional[str] = Query(None),
115
+ db: Session = Depends(get_db),
116
+ ):
117
+ _require_dda()
118
+ query = db.query(ImageAsset).order_by(ImageAsset.created_at.desc())
119
+ if zone_id:
120
+ query = query.filter(ImageAsset.zone_id == zone_id)
121
+ if village_id:
122
+ query = query.filter(ImageAsset.village_id == village_id)
123
+ if year:
124
+ query = query.filter(ImageAsset.year == year)
125
+ if q:
126
+ like = f"%{q.strip()}%"
127
+ query = query.filter(
128
+ (ImageAsset.area_name.ilike(like))
129
+ | (ImageAsset.original_filename.ilike(like))
130
+ | (ImageAsset.grid_id.ilike(like))
131
+ )
132
+ assets = query.limit(200).all()
133
+ zone_map = {z.id: z.name for z in db.query(DdaZone).all()}
134
+ village_map = {v.id: v.name for v in db.query(DdaVillage).all()}
135
+ return [
136
+ _image_to_dict(
137
+ a,
138
+ zone_name=zone_map.get(a.zone_id, ""),
139
+ village_name=village_map.get(a.village_id, ""),
140
+ )
141
+ for a in assets
142
+ ]
143
+
144
+
145
+ @router.get("/images/{image_id}")
146
+ def get_image(image_id: int, db: Session = Depends(get_db)):
147
+ _require_dda()
148
+ asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
149
+ if not asset:
150
+ raise HTTPException(status_code=404, detail="Image not found")
151
+ zone_name = ""
152
+ village_name = ""
153
+ if asset.zone_id:
154
+ z = db.query(DdaZone).filter(DdaZone.id == asset.zone_id).first()
155
+ zone_name = z.name if z else ""
156
+ if asset.village_id:
157
+ v = db.query(DdaVillage).filter(DdaVillage.id == asset.village_id).first()
158
+ village_name = v.name if v else ""
159
+ return _image_to_dict(asset, zone_name=zone_name, village_name=village_name)
160
+
161
+
162
+ @router.get("/images/{image_id}/thumb")
163
+ def get_image_thumb(image_id: int, db: Session = Depends(get_db)):
164
+ _require_dda()
165
+ asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
166
+ if not asset or not asset.thumb_path:
167
+ raise HTTPException(status_code=404, detail="Thumbnail not found")
168
+ path = LIBRARY_DIR.parent / asset.thumb_path
169
+ if not path.exists():
170
+ raise HTTPException(status_code=404, detail="Thumbnail file missing")
171
+ return FileResponse(path, media_type="image/png")
172
+
173
+
174
+ @router.post("/images/upload")
175
+ async def upload_image(
176
+ file: UploadFile = File(...),
177
+ zone_id: int = Form(...),
178
+ village_id: int = Form(...),
179
+ area_name: str = Form(""),
180
+ year: int = Form(...),
181
+ capture_date: str = Form(...),
182
+ source: str = Form("satellite"),
183
+ grid_id: str = Form(""),
184
+ manual_bounds_json: str = Form(""),
185
+ db: Session = Depends(get_db),
186
+ ):
187
+ _require_dda()
188
+ ensure_library_dirs()
189
+ user = get_or_create_guest_user(db)
190
+
191
+ zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
192
+ village = db.query(DdaVillage).filter(DdaVillage.id == village_id, DdaVillage.zone_id == zone_id).first()
193
+ if not zone or not village:
194
+ raise HTTPException(status_code=400, detail="Invalid zone or village")
195
+
196
+ original = file.filename or "upload"
197
+ ext = Path(original).suffix.lower()
198
+ if ext not in ALLOWED_EXTENSIONS:
199
+ raise HTTPException(
200
+ status_code=400,
201
+ detail=f"Unsupported format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
202
+ )
203
+
204
+ raw = await file.read()
205
+ if not raw:
206
+ raise HTTPException(status_code=400, detail="File is empty")
207
+ if len(raw) > MAX_GEOTIFF_BYTES:
208
+ raise HTTPException(
209
+ status_code=400,
210
+ detail=f"File too large (max {MAX_GEOTIFF_BYTES // (1024 * 1024)} MB)",
211
+ )
212
+
213
+ try:
214
+ cap_date = date.fromisoformat(capture_date.strip())
215
+ except ValueError:
216
+ raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
217
+
218
+ if source not in ("satellite", "drone"):
219
+ raise HTTPException(status_code=400, detail="source must be satellite or drone")
220
+
221
+ asset_uuid = uuid.uuid4().hex
222
+ stored_name = f"{asset_uuid}{ext}"
223
+ dest_file = LIBRARY_DIR / str(year) / stored_name
224
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
225
+ dest_file.write_bytes(raw)
226
+
227
+ ingest = inspect_image(dest_file)
228
+ has_georef = ingest.has_georef
229
+ manual_location = manual_bounds_json.strip()
230
+
231
+ if ext in (".tif", ".tiff") and not has_georef and not manual_location:
232
+ dest_file.unlink(missing_ok=True)
233
+ raise HTTPException(
234
+ status_code=400,
235
+ detail="GeoTIFF has no embedded georeferencing. Provide manual_bounds_json (west,south,east,north in WGS84).",
236
+ )
237
+
238
+ thumb_file = THUMBS_DIR / f"{asset_uuid}.png"
239
+ preview_file = PREVIEWS_DIR / f"{asset_uuid}.png"
240
+ try:
241
+ raster_to_preview_png(dest_file, thumb_file, max_side=256)
242
+ raster_to_preview_png(dest_file, preview_file, max_side=1024)
243
+ except Exception as exc:
244
+ dest_file.unlink(missing_ok=True)
245
+ raise HTTPException(status_code=400, detail=f"Could not generate preview: {exc}")
246
+
247
+ rel_file = str(dest_file.relative_to(LIBRARY_DIR.parent))
248
+ rel_thumb = str(thumb_file.relative_to(LIBRARY_DIR.parent))
249
+ rel_preview = str(preview_file.relative_to(LIBRARY_DIR.parent))
250
+
251
+ bounds_json = bounds_to_json(ingest.bounds_wgs84)
252
+ if not bounds_json and manual_location:
253
+ try:
254
+ parts = [float(x.strip()) for x in manual_location.replace("[", "").replace("]", "").split(",")]
255
+ if len(parts) == 4:
256
+ bounds_json = bounds_to_json(tuple(parts))
257
+ except (ValueError, TypeError):
258
+ pass
259
+
260
+ asset = ImageAsset(
261
+ uuid=asset_uuid,
262
+ zone_id=zone_id,
263
+ village_id=village_id,
264
+ area_name=area_name.strip(),
265
+ year=year,
266
+ grid_id=grid_id.strip(),
267
+ capture_date=cap_date,
268
+ source=source,
269
+ format=ingest.format,
270
+ original_filename=original,
271
+ file_path=rel_file,
272
+ thumb_path=rel_thumb,
273
+ preview_path=rel_preview,
274
+ crs=ingest.crs,
275
+ bounds_json=bounds_json,
276
+ has_georef=has_georef or bool(bounds_json),
277
+ manual_location_json=manual_location,
278
+ width=ingest.width,
279
+ height=ingest.height,
280
+ file_size_bytes=len(raw),
281
+ uploaded_by=user.id,
282
+ )
283
+ db.add(asset)
284
+ db.commit()
285
+ db.refresh(asset)
286
+
287
+ return {
288
+ "status": "success",
289
+ "image": _image_to_dict(asset, zone_name=zone.name, village_name=village.name),
290
+ }
app/dda/models.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Text
5
+ from sqlalchemy.orm import relationship
6
+
7
+ from ..database import Base
8
+
9
+
10
+ def _utcnow():
11
+ return datetime.now(timezone.utc)
12
+
13
+
14
+ class DdaZone(Base):
15
+ __tablename__ = "dda_zones"
16
+
17
+ id = Column(Integer, primary_key=True, index=True)
18
+ name = Column(String(128), unique=True, nullable=False, index=True)
19
+ mode = Column(String(32), default="admin") # admin | grid_parent
20
+ created_at = Column(DateTime, default=_utcnow)
21
+
22
+ villages = relationship("DdaVillage", back_populates="zone", cascade="all, delete-orphan")
23
+
24
+
25
+ class DdaVillage(Base):
26
+ __tablename__ = "dda_villages"
27
+
28
+ id = Column(Integer, primary_key=True, index=True)
29
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
30
+ name = Column(String(128), nullable=False, index=True)
31
+ created_at = Column(DateTime, default=_utcnow)
32
+
33
+ zone = relationship("DdaZone", back_populates="villages")
34
+ images = relationship("ImageAsset", back_populates="village")
35
+
36
+
37
+ class ImageAsset(Base):
38
+ """Centralized satellite / drone image (FR-01, FR-02)."""
39
+ __tablename__ = "dda_image_assets"
40
+
41
+ id = Column(Integer, primary_key=True, index=True)
42
+ uuid = Column(String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4()), index=True)
43
+ zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=True, index=True)
44
+ village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=True, index=True)
45
+ area_name = Column(String(128), default="")
46
+ year = Column(Integer, nullable=True, index=True)
47
+ grid_id = Column(String(64), default="", index=True)
48
+
49
+ capture_date = Column(Date, nullable=True)
50
+ source = Column(String(32), default="satellite") # satellite | drone
51
+ format = Column(String(32), default="geotiff")
52
+ original_filename = Column(String(255), default="")
53
+
54
+ file_path = Column(String(512), nullable=False)
55
+ thumb_path = Column(String(512), default="")
56
+ preview_path = Column(String(512), default="")
57
+
58
+ crs = Column(String(64), default="")
59
+ bounds_json = Column(Text, default="") # WGS84 [west, south, east, north]
60
+ has_georef = Column(Boolean, default=False)
61
+ manual_location_json = Column(Text, default="")
62
+
63
+ width = Column(Integer, default=0)
64
+ height = Column(Integer, default=0)
65
+ file_size_bytes = Column(Integer, default=0)
66
+
67
+ uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
68
+ created_at = Column(DateTime, default=_utcnow)
69
+
70
+ zone = relationship("DdaZone")
71
+ village = relationship("DdaVillage", back_populates="images")
72
+
73
+
74
+ class DetectionJob(Base):
75
+ """Async change detection job (FR-04 skeleton)."""
76
+ __tablename__ = "dda_detection_jobs"
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)
85
+ error_message = Column(Text, default="")
86
+ notify_email = Column(String(255), default="")
87
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
88
+ started_at = Column(DateTime, nullable=True)
89
+ completed_at = Column(DateTime, nullable=True)
90
+ created_at = Column(DateTime, default=_utcnow)
app/dda/seed.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ from .models import DdaVillage, DdaZone
6
+ from .seed_data import DELHI_ZONES
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def seed_delhi_hierarchy(db: Session) -> dict:
12
+ """Insert Zone → Village hierarchy if empty. Idempotent."""
13
+ existing = db.query(DdaZone).count()
14
+ if existing > 0:
15
+ return {"seeded": False, "zones": existing}
16
+
17
+ zones_created = 0
18
+ villages_created = 0
19
+ for zone_name, villages in DELHI_ZONES.items():
20
+ zone = DdaZone(name=zone_name, mode="admin")
21
+ db.add(zone)
22
+ db.flush()
23
+ zones_created += 1
24
+ for village_name in villages:
25
+ db.add(DdaVillage(zone_id=zone.id, name=village_name))
26
+ villages_created += 1
27
+
28
+ db.commit()
29
+ logger.info("DDA seed: %d zones, %d villages", zones_created, villages_created)
30
+ return {"seeded": True, "zones": zones_created, "villages": villages_created}
app/dda/seed_data.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Delhi administrative hierarchy — seeded into DB for DDA library (FR-01)."""
2
+
3
+ DELHI_ZONES = {
4
+ "Central Delhi": [
5
+ "Karol Bagh", "Paharganj", "Daryaganj", "Rajinder Nagar", "Patel Nagar",
6
+ "Anand Parbat", "Bapa Nagar", "Prasad Nagar", "Dev Nagar", "Old Rajinder Nagar",
7
+ ],
8
+ "New Delhi": [
9
+ "Connaught Place", "Chanakyapuri", "Lodhi Road", "Mandi House",
10
+ "India Gate", "Khan Market", "Barakhamba", "Gole Market", "Sansad Marg",
11
+ ],
12
+ "North Delhi": [
13
+ "Civil Lines", "Model Town", "Sadar Bazaar", "Timarpur", "Gulabi Bagh",
14
+ "Kamla Nagar", "Shakti Nagar", "Roop Nagar", "Vijay Nagar", "Mukherjee Nagar",
15
+ "GTB Nagar", "Adarsh Nagar", "Azadpur", "Wazirabad",
16
+ ],
17
+ "North West Delhi": [
18
+ "Rohini", "Narela", "Bawana", "Alipur", "Shalimar Bagh",
19
+ "Pitampura", "Kanjhawala", "Mundka", "Sultanpuri", "Mangolpuri",
20
+ "Begumpur", "Pooth Kalan", "Holambi Kalan", "Bankner", "Siraspur",
21
+ ],
22
+ "North East Delhi": [
23
+ "Seelampur", "Jafrabad", "Mustafabad", "Babarpur", "Gokulpuri",
24
+ "Yamuna Vihar", "Karawal Nagar", "Dayalpur", "Khajuri Khas",
25
+ "Bhajanpura", "Harsh Vihar", "Brahmpuri", "Ghonda",
26
+ ],
27
+ "East Delhi": [
28
+ "Preet Vihar", "Laxmi Nagar", "Mayur Vihar Phase I", "Mayur Vihar Phase II",
29
+ "Mayur Vihar Phase III", "Patparganj", "Pandav Nagar", "Shakarpur",
30
+ "Mandawali", "Kalyanpuri", "Trilokpuri", "Kondli", "Gharoli",
31
+ "Khichripur", "Anand Vihar",
32
+ ],
33
+ "Shahdara": [
34
+ "Shahdara", "Vivek Vihar", "Dilshad Garden", "Seema Puri", "New Seelampur",
35
+ "Nand Nagri", "Harsh Vihar", "Jhilmil Colony", "Mansarovar Park",
36
+ ],
37
+ "South Delhi": [
38
+ "Hauz Khas", "Mehrauli", "Saket", "Kalkaji", "Greater Kailash",
39
+ "Malviya Nagar", "Vasant Kunj", "Chattarpur", "Lado Sarai",
40
+ "Fatehpur Beri", "Mandi Village", "Dera Village", "Aaya Nagar",
41
+ "Sultanpur", "Ghitorni", "Satbari", "Jonapur", "Asola",
42
+ ],
43
+ "South East Delhi": [
44
+ "Defence Colony", "Okhla", "Jamia Nagar", "Badarpur", "Jaitpur",
45
+ "Madanpur Khadar", "Sarita Vihar", "Jasola", "Sukhdev Vihar",
46
+ "Tughlakabad", "Sangam Vihar", "Mithapur", "Pul Pehlad",
47
+ ],
48
+ "South West Delhi": [
49
+ "Dwarka", "Najafgarh", "Kapashera", "Palam", "Dabri",
50
+ "Mahavir Enclave", "Bindapur", "Uttam Nagar", "Nasirpur",
51
+ "Chhawla", "Dichaon Kalan", "Ghumanhera", "Jhatikara",
52
+ "Rawta", "Pochanpur", "Bijwasan", "Sarangpur", "Paprawat",
53
+ ],
54
+ "West Delhi": [
55
+ "Rajouri Garden", "Janakpuri", "Tilak Nagar", "Vikaspuri",
56
+ "Hari Nagar", "Subhash Nagar", "Tagore Garden", "Moti Nagar",
57
+ "Kirti Nagar", "Punjabi Bagh", "Nangloi Jat", "Nilothi",
58
+ "Mundka", "Madipur", "Paschim Vihar",
59
+ ],
60
+ }
app/main.py CHANGED
@@ -27,6 +27,10 @@ from .auth import (
27
  )
28
  from .database import Base, engine, get_db, DATA_DIR
29
  from .models import User, DetectionRun
 
 
 
 
30
  from .notifier import send_notification, send_test_email
31
 
32
  import logging
@@ -66,18 +70,25 @@ try:
66
  except Exception:
67
  conn.rollback()
68
  logger.info("Database initialization complete")
 
69
  except Exception as e:
70
  import logging
71
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
72
 
73
- app = FastAPI(title="AI Change Detection", version="2.2.0")
 
74
 
75
 
76
  @app.get("/health")
77
  def health():
78
  """Lightweight health check so Hugging Face can mark the Space as running quickly."""
79
  from datetime import datetime
80
- return {"status": "ok", "version": "2.2.0", "server_time_ist": _isoformat_ist(datetime.now(timezone.utc))}
 
 
 
 
 
81
 
82
 
83
  @app.on_event("startup")
@@ -541,7 +552,10 @@ def delete_run(
541
  # --- Serve SPA ---
542
  @app.get("/", response_class=HTMLResponse)
543
  def index():
544
- index_file = TEMPLATES_DIR / "index.html"
 
 
 
545
  if not index_file.exists():
546
  return HTMLResponse("<h1>Satellite Change Detection</h1><p>Create <code>templates/index.html</code> and <code>static/</code>.</p>")
547
  return FileResponse(index_file)
 
27
  )
28
  from .database import Base, engine, get_db, DATA_DIR
29
  from .models import User, DetectionRun
30
+ from . import dda as _dda_pkg # noqa: F401 — register DDA tables
31
+ from .dda.models import DdaZone, DdaVillage, ImageAsset, DetectionJob # noqa: F401
32
+ from .dda.config import IS_DDA_MODE
33
+ from .dda.bootstrap import init_dda_database, setup_dda
34
  from .notifier import send_notification, send_test_email
35
 
36
  import logging
 
70
  except Exception:
71
  conn.rollback()
72
  logger.info("Database initialization complete")
73
+ init_dda_database()
74
  except Exception as e:
75
  import logging
76
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
77
 
78
+ app = FastAPI(title="AI Change Detection", version="2.3.0-dda" if IS_DDA_MODE else "2.2.0")
79
+ setup_dda(app)
80
 
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
+ "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
91
+ }
92
 
93
 
94
  @app.on_event("startup")
 
552
  # --- Serve SPA ---
553
  @app.get("/", response_class=HTMLResponse)
554
  def index():
555
+ if IS_DDA_MODE:
556
+ index_file = TEMPLATES_DIR / "index_dda.html"
557
+ else:
558
+ index_file = TEMPLATES_DIR / "index.html"
559
  if not index_file.exists():
560
  return HTMLResponse("<h1>Satellite Change Detection</h1><p>Create <code>templates/index.html</code> and <code>static/</code>.</p>")
561
  return FileResponse(index_file)
docs/IMPLEMENTATION_PLAN_DDA.md ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DDA Change Detection — Implementation Plan (Dev Space Only)
2
+
3
+ **Document:** Scope of Work → Engineering Plan
4
+ **Client:** DDA (Delhi Development Authority)
5
+ **Target environment:** Hugging Face **`satdetect-dev`** only (`hf-dev` remote, `master` branch)
6
+ **Production (`satdetect`) must not receive these changes until explicit sign-off.**
7
+
8
+ ---
9
+
10
+ ## 1. Executive summary
11
+
12
+ The SOW transforms the current “upload two PNGs → run detection → view history” demo into a **departmental change-monitoring system** with:
13
+
14
+ - A **persistent image library** (GeoTIFF, hierarchical metadata, search)
15
+ - **Library-driven comparison** (Base T1 vs Comparison T2)
16
+ - **Async AI detection** with geo-referenced outputs
17
+ - **Reports** (browser + PDF + email)
18
+ - **Geo-navigation** and **clickable regions**
19
+ - **Human review** (Confirmed / False Positive) and **export to departmental systems**
20
+
21
+ The existing codebase is a strong **Phase-0 foundation**: AdaptFormer-based detection, region classification, overlay UI, email notifier, and history. Roughly **40% of FR-07** exists; **FR-01–FR-06 and FR-08 are largely net-new**.
22
+
23
+ **Recommended approach:** Implement behind a **`APP_MODE=dda`** feature flag on dev, extend the database and API vertically, and keep production on the current simplified flow indefinitely.
24
+
25
+ ---
26
+
27
+ ## 2. SOW requirements → current state
28
+
29
+ | ID | Requirement | Current state | Gap severity |
30
+ |----|-------------|---------------|--------------|
31
+ | **FR-01** | Centralized image library (Zone → Village → Area → Year or Grid) | Zone/Village are free-text dropdowns on upload; images discarded after run except thumbnails | **Large** |
32
+ | **FR-02** | GeoTIFF upload + validation + manual georef fallback | PNG/JPEG only, 20 MB, Pillow RGB load | **Large** |
33
+ | **FR-03** | Pick Base/Comparison from library or drag from tree | Local file upload only | **Medium** (depends on FR-01) |
34
+ | **FR-04** | AI detection as background job; simplified change types; lat/lng per region | Synchronous `/api/detect`; rich internal taxonomy; pixel coords only | **Medium–Large** |
35
+ | **FR-05** | In-app notification + report link; PDF export; persistent reports | Email optional; HTML modal report; no PDF; history exists | **Medium** |
36
+ | **FR-06** | Navigate to change by lat/lng; Locate action | No geospatial metadata | **Large** (depends on FR-02) |
37
+ | **FR-07** | Clickable regions auto-pan viewer; side-by-side Base/Comparison | Region row hover + compare slider partially exist | **Small–Medium** |
38
+ | **FR-08** | Confirm / False Positive; submit to dept API or export | Not implemented | **Medium** |
39
+
40
+ ### SOW change-type mapping (canonical output)
41
+
42
+ Map internal `detection_engine` labels → DDA report types:
43
+
44
+ | DDA type | Internal sources (examples) |
45
+ |----------|------------------------------|
46
+ | **New Construction** | New Construction/Building, Temporary Structure, Road/Pavement Change (New) |
47
+ | **Demolition** | Demolition/Clearing, Partial/Full Demolition |
48
+ | **Extension** | Expansion, Widening, Renovation |
49
+ | **Vegetation Change** | Vegetation Change (+ sub-types) |
50
+ | **Other** | Water Body, Bare Land/Soil, Unclassified |
51
+
52
+ Store both `dda_change_type` (report) and `internal_object_type` (model/debug) on each region.
53
+
54
+ ---
55
+
56
+ ## 3. Deployment & branching strategy
57
+
58
+ ```
59
+ master (DDA features) ──push──► hf-dev → coderuday21/satdetect-dev
60
+ production ──push──► hf → coderuday21/satdetect (frozen)
61
+ ```
62
+
63
+ ### Dev-only safeguards
64
+
65
+ 1. **`APP_MODE` env var** on `satdetect-dev`: `dda` enables library, GeoTIFF, jobs, RBAC, review UI. Default / production: `legacy`.
66
+ 2. **Separate DB file** on dev: `data/dda_app.db` (avoid mixing guest history with library assets).
67
+ 3. **All new routes** under `/api/dda/*` or gated by `APP_MODE` in `main.py`.
68
+ 4. **CI checklist** before any production promote: diff must not touch DDA modules unless mode flag documented.
69
+
70
+ ---
71
+
72
+ ## 4. Target architecture
73
+
74
+ ```mermaid
75
+ flowchart TB
76
+ subgraph ui [Frontend SPA]
77
+ LIB[Image Library Tree + Search]
78
+ CMP[Comparison Picker T1/T2]
79
+ JOB[Job Status / Notifications]
80
+ RPT[Report Viewer + PDF]
81
+ REV[Review Confirmed / FP]
82
+ end
83
+
84
+ subgraph api [FastAPI]
85
+ AUTH[RBAC Auth]
86
+ IMG[Image Library API]
87
+ DET[Detection Job API]
88
+ RPTAPI[Report + Export API]
89
+ DEPT[Department Export Adapter]
90
+ end
91
+
92
+ subgraph data [Storage]
93
+ DB[(SQLite / PostgreSQL)]
94
+ FS[File Store: GeoTIFF + pyramids + overlays]
95
+ JQ[Job Queue Table]
96
+ end
97
+
98
+ subgraph proc [Processing]
99
+ GEO[rasterio ingest + CRS normalize]
100
+ PIPE[detection_engine.run_detection]
101
+ GEOOUT[Region centroid → lat/lng]
102
+ end
103
+
104
+ LIB --> IMG
105
+ CMP --> DET
106
+ DET --> JQ
107
+ JQ --> proc
108
+ proc --> DB
109
+ proc --> FS
110
+ JOB --> DET
111
+ RPT --> RPTAPI
112
+ REV --> DEPT
113
+ AUTH --> api
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 5. Data model (new & extended)
119
+
120
+ ### 5.1 Hierarchy (FR-01)
121
+
122
+ Two organization modes (user selects at library root):
123
+
124
+ **A. Administrative (default for DDA Delhi)**
125
+ `Zone` → `Village` → `Area` → `Year` → `[ImageAsset…]`
126
+
127
+ **B. Grid**
128
+ `GridId` (e.g. `G-12-04`) → `Year` → `[ImageAsset…]`
129
+
130
+ Implement as normalized tables (not hardcoded JS):
131
+
132
+ ```text
133
+ zones(id, name, mode) -- mode: admin | grid_parent
134
+ villages(id, zone_id, name) -- nullable for grid mode
135
+ areas(id, village_id, name)
136
+ library_years(id, area_id, year) -- optional grouping node; images also store year directly
137
+ ```
138
+
139
+ For MVP, **Area** can be a free-text field on `ImageAsset` if DDA taxonomy is not finalized.
140
+
141
+ ### 5.2 `ImageAsset` (FR-01, FR-02)
142
+
143
+ | Column | Purpose |
144
+ |--------|---------|
145
+ | `id`, `uuid` | Primary identifiers |
146
+ | `zone_id`, `village_id`, `area_name`, `year`, `grid_id` | Hierarchy |
147
+ | `capture_date` | Required metadata |
148
+ | `source` | `satellite` \| `drone` |
149
+ | `format` | `geotiff` |
150
+ | `file_path`, `thumb_path`, `preview_path` | Storage |
151
+ | `crs`, `bounds_json` | Geo metadata (WGS84 bbox) |
152
+ | `width`, `height`, `file_size_bytes` | Validation |
153
+ | `has_georef` | bool |
154
+ | `manual_location_json` | Fallback corner coords + notes if no embedded georef |
155
+ | `uploaded_by`, `created_at` | Audit |
156
+
157
+ ### 5.3 `DetectionJob` (FR-04 async)
158
+
159
+ | Column | Purpose |
160
+ |--------|---------|
161
+ | `id`, `status` | `queued` \| `running` \| `completed` \| `failed` |
162
+ | `base_image_id`, `comparison_image_id` | FK → ImageAsset |
163
+ | `method`, `params_json` | Detection settings |
164
+ | `run_id` | FK → DetectionRun when done |
165
+ | `error_message`, `started_at`, `completed_at` | Job lifecycle |
166
+ | `notify_email`, `notify_in_app` | FR-05 |
167
+
168
+ ### 5.4 Extend `DetectionRun`
169
+
170
+ Add: `base_image_id`, `comparison_image_id`, `job_id`, `report_pdf_path`, `status` (`draft` \| `published`).
171
+
172
+ ### 5.5 Extend region JSON schema
173
+
174
+ Each region gains:
175
+
176
+ ```json
177
+ {
178
+ "ddaChangeType": "New Construction",
179
+ "confidence": 0.87,
180
+ "areaSqM": 142.5,
181
+ "centroid": { "x": 412, "y": 288 },
182
+ "latLng": { "lat": 28.6139, "lng": 77.2090 },
183
+ "bbox": { "x": 380, "y": 260, "w": 64, "h": 56 },
184
+ "reviewStatus": "pending",
185
+ "reviewedBy": null,
186
+ "reviewedAt": null
187
+ }
188
+ ```
189
+
190
+ ### 5.6 `RegionReview` + export batch (FR-08)
191
+
192
+ ```text
193
+ region_reviews(id, run_id, region_id, status, reviewer_id, notes, submitted_at)
194
+ export_batches(id, run_id, format, payload_path, dept_api_response, created_at)
195
+ ```
196
+
197
+ ### 5.7 RBAC (FR-01, FR-02)
198
+
199
+ Extend `User`:
200
+
201
+ ```text
202
+ role: viewer | uploader | analyst | admin
203
+ ```
204
+
205
+ | Role | View library | Upload | Run detection | Review/submit |
206
+ |------|--------------|--------|---------------|---------------|
207
+ | viewer | ✓ | | | |
208
+ | uploader | ✓ | ✓ | | |
209
+ | analyst | ✓ | ✓ | ✓ | ✓ |
210
+ | admin | ✓ | ✓ | ✓ | ✓ + manage hierarchy |
211
+
212
+ **Dev Space:** Re-enable login UI when `APP_MODE=dda`. Production stays guest/no-login.
213
+
214
+ ---
215
+
216
+ ## 6. API design (dev / DDA mode)
217
+
218
+ ### 6.1 Image library
219
+
220
+ | Method | Path | Description |
221
+ |--------|------|-------------|
222
+ | `GET` | `/api/dda/hierarchy` | Tree: zones → villages → areas → years (+ counts) |
223
+ | `GET` | `/api/dda/images` | Filter: `zone`, `village`, `area`, `year`, `date_from`, `date_to`, `grid_id`, `q` |
224
+ | `POST` | `/api/dda/images/upload` | Multipart GeoTIFF + metadata form |
225
+ | `GET` | `/api/dda/images/{id}` | Metadata + thumb URL |
226
+ | `GET` | `/api/dda/images/{id}/preview` | Downsampled PNG/Web tile for viewer |
227
+ | `DELETE` | `/api/dda/images/{id}` | Admin only |
228
+
229
+ **Upload validation (FR-02):**
230
+
231
+ 1. Extension `.tif` / `.tiff`
232
+ 2. Max size: **500 MB dev** (configurable; DDA responsible for suitable resolution per SOW)
233
+ 3. `rasterio.open()` — verify readable, extract CRS/transform
234
+ 4. If no georef: require `manual_bounds` (SW/NE lat-lng) + `capture_date` before commit
235
+ 5. Generate 512px thumbnail + 2048px preview for UI
236
+ 6. Return `{ id, status: "success" | "failed", errors[] }`
237
+
238
+ ### 6.2 Comparison & detection jobs
239
+
240
+ | Method | Path | Description |
241
+ |--------|------|-------------|
242
+ | `POST` | `/api/dda/jobs` | Body: `{ baseImageId, comparisonImageId, method?, notifyEmail? }` → `{ jobId }` |
243
+ | `GET` | `/api/dda/jobs/{id}` | Status + progress + runId when complete |
244
+ | `GET` | `/api/dda/jobs` | User's recent jobs (in-app notification feed) |
245
+ | `POST` | `/api/dda/detect` | **Legacy sync path** for small previews only (keep for HF debugging) |
246
+
247
+ **Job runner logic:**
248
+
249
+ ```text
250
+ POST /jobs → insert DetectionJob(queued)
251
+ → return 202 + jobId
252
+ Background worker thread:
253
+ 1. Load ImageAsset paths via rasterio
254
+ 2. Align pair (same CRS; reproject if needed)
255
+ 3. Windowed read → RGB numpy arrays (max 1600px for DL)
256
+ 4. run_detection(...)
257
+ 5. For each region: pixel centroid → lat/lng via transform
258
+ 6. Map object types → ddaChangeType
259
+ 7. Save DetectionRun + overlay + update job completed
260
+ 8. Fire notification (in-app row + optional email with report link)
261
+ ```
262
+
263
+ **HF timeout mitigation:** Job endpoint returns immediately; worker runs in-process with status polling. For GeoTIFFs >60s, use **pyramid preview** for detection (DDA supplies suitable resolution) and store full metadata separately. Document in UI: “Analysis uses optimized resolution; full GeoTIFF retained in library.”
264
+
265
+ ### 6.3 Reports (FR-05)
266
+
267
+ | Method | Path | Description |
268
+ |--------|------|-------------|
269
+ | `GET` | `/api/dda/reports/{run_id}` | Full report JSON (existing shape + geo fields) |
270
+ | `GET` | `/api/dda/reports/{run_id}/pdf` | Generate/download PDF |
271
+ | `POST` | `/api/dda/reports/{run_id}/notify` | Email with link to report |
272
+
273
+ **PDF engine:** `weasyprint` or `reportlab` — HTML template reuse from `templates/ChangeDetection.html`.
274
+
275
+ **In-app notification:** Bell icon + `/api/dda/jobs?status=completed&unseen=true`.
276
+
277
+ ### 6.4 Geo-navigation (FR-06, FR-07)
278
+
279
+ No external map SDK required for MVP — **image viewer geo mode**:
280
+
281
+ - Report shows `lat, lng` per region
282
+ - **Locate** button: pan/zoom canvas to bbox center at 200% zoom, pulse highlight
283
+ - Optional Phase 2: Leaflet minimap with footprint rectangle
284
+
285
+ Frontend already has region hover → bbox highlight; extend to **click row → scroll viewer + zoom**.
286
+
287
+ Side-by-side: keep existing compare slider; add locked **T1 | T2 | Overlay** tabs when images loaded from library.
288
+
289
+ ### 6.5 Review & departmental export (FR-08)
290
+
291
+ | Method | Path | Description |
292
+ |--------|------|-------------|
293
+ | `PATCH` | `/api/dda/reports/{run_id}/regions/{region_id}` | `{ reviewStatus: confirmed \| false_positive, notes? }` |
294
+ | `POST` | `/api/dda/reports/{run_id}/submit` | Submit confirmed only |
295
+ | `GET` | `/api/dda/reports/{run_id}/export.{csv\|xlsx\|pdf}` | Structured export |
296
+
297
+ **Department adapter pattern:**
298
+
299
+ ```python
300
+ class DepartmentExporter(Protocol):
301
+ def submit(self, run: DetectionRun, regions: list) -> ExportResult: ...
302
+
303
+ class ApiExporter(DepartmentExporter): # DEPT_API_URL + DEPT_API_KEY
304
+ class FileExporter(DepartmentExporter): # CSV/XLSX fallback
305
+ ```
306
+
307
+ False positives: persist in `region_reviews` with `status=false_positive` for future training export (`/api/dda/training/export` — admin only, Phase 2).
308
+
309
+ ---
310
+
311
+ ## 7. GeoTIFF processing pipeline (accurate logic)
312
+
313
+ ### 7.1 Ingest
314
+
315
+ ```python
316
+ def ingest_geotiff(path: Path) -> GeoImageMeta:
317
+ with rasterio.open(path) as src:
318
+ crs = src.crs
319
+ bounds = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
320
+ has_georef = crs is not None and src.transform != rasterio.identity()
321
+ # Prefer RGB or first 3 bands; handle 1-band grayscale
322
+ preview = read_downsampled(src, max_side=2048)
323
+ return meta
324
+ ```
325
+
326
+ **Dependencies to add (dev only branch):** `rasterio`, `pyproj` (GDAL wheels on Docker).
327
+
328
+ ### 7.2 Pair compatibility check (before job starts)
329
+
330
+ | Check | Action |
331
+ |-------|--------|
332
+ | Same CRS or reproject T2 → T1 CRS | Reproject comparison raster |
333
+ | Overlapping bounds | Warn if <50% overlap |
334
+ | Band count | Harmonize to RGB |
335
+ | Resolution ratio | Warn if >2× GSD difference |
336
+
337
+ ### 7.3 Pixel → lat/lng
338
+
339
+ ```python
340
+ def region_centroid_latlng(region_bbox, transform, crs):
341
+ cx = region_bbox.x + region_bbox.w / 2
342
+ cy = region_bbox.y + region_bbox.h / 2
343
+ lng, lat = rasterio.transform.xy(transform, cy, cx, offset="center")
344
+ if crs != EPSG:4326:
345
+ lng, lat = transform_coords(crs, 4326, lng, lat)
346
+ return lat, lng
347
+ ```
348
+
349
+ ### 7.4 Area in m²
350
+
351
+ Use pixel area × GSD² from transform (for projected CRS in meters).
352
+
353
+ ---
354
+
355
+ ## 8. Frontend plan (dev UI)
356
+
357
+ Replace single upload card with **tabbed workflow** when `APP_MODE=dda`:
358
+
359
+ ### Tab 1 — Image Library
360
+ - Left: collapsible tree (Zone → Village → Area → Year)
361
+ - Right: grid of thumbnails + metadata
362
+ - Upload modal: GeoTIFF + form (hierarchy, capture date, source, manual bounds if needed)
363
+ - Search bar + filters
364
+
365
+ ### Tab 2 — Change Detection
366
+ - Two slots: **Base (T1)** | **Comparison (T2)**
367
+ - Drag image from library OR picker dialog
368
+ - Show thumb + capture date for each
369
+ - Run → job submitted → progress spinner → auto-open report on complete
370
+
371
+ ### Tab 3 — Reports & Review
372
+ - List of completed runs
373
+ - Open report → regions table with DDA types, lat/lng, confidence, area
374
+ - **Locate** per row; click row → pan viewer
375
+ - Toggle Confirmed / False Positive
376
+ - Submit to department / Download export
377
+
378
+ ### Tab 4 — Notifications
379
+ - Job completion feed (FR-05 in-app)
380
+
381
+ **Auth screens:** Login/register return on dev only (`APP_MODE=dda`).
382
+
383
+ ---
384
+
385
+ ## 9. Implementation phases
386
+
387
+ ### Phase 0 — Foundation (3–4 days)
388
+ - [ ] `APP_MODE` flag + route gating in `main.py`
389
+ - [ ] Alembic-style migrations or existing startup migrations for new tables
390
+ - [ ] `rasterio` in `requirements.txt` + Docker GDAL base image update (dev Dockerfile branch)
391
+ - [ ] Seed script: import current `DELHI_ZONES` into DB
392
+ - [ ] Dev README section
393
+
394
+ ### Phase 1 — Image library (FR-01, FR-02) (5–7 days)
395
+ - [ ] Models: hierarchy + ImageAsset
396
+ - [ ] Upload API + validation + thumbnail generation
397
+ - [ ] Library tree + search UI
398
+ - [ ] RBAC: uploader vs viewer
399
+
400
+ ### Phase 2 — Library-based comparison (FR-03) (2–3 days)
401
+ - [ ] T1/T2 picker from library
402
+ - [ ] Drag-and-drop from tree to comparison slots
403
+ - [ ] Preview + capture date display
404
+
405
+ ### Phase 3 — Async detection + geo output (FR-04, FR-06) (5–6 days)
406
+ - [ ] DetectionJob worker + polling API
407
+ - [ ] GeoTIFF → RGB pipeline integration with `detection_engine`
408
+ - [ ] DDA change type mapping + lat/lng + area m² on regions
409
+ - [ ] Job failure handling + user messaging
410
+
411
+ ### Phase 4 — Reports & notifications (FR-05) (3–4 days)
412
+ - [ ] In-app notification feed
413
+ - [ ] Report page (browser)
414
+ - [ ] PDF export
415
+ - [ ] Email with report deep link
416
+
417
+ ### Phase 5 — Interactive viewer (FR-07) (2–3 days)
418
+ - [ ] Locate button + click-to-pan/zoom
419
+ - [ ] Side-by-side T1/T2/overlay modes
420
+ - [ ] Region highlight sync (extend existing hover logic)
421
+
422
+ ### Phase 6 — Review & export (FR-08) (4–5 days)
423
+ - [ ] Per-region review state
424
+ - [ ] CSV/XLSX/PDF export of confirmed changes
425
+ - [ ] Department API adapter (config-driven)
426
+ - [ ] False-positive archive for training
427
+
428
+ ### Phase 7 — Hardening & UAT (3–5 days)
429
+ - [ ] Load test with sample GeoTIFFs from DDA
430
+ - [ ] Alignment QA on drone vs satellite pairs
431
+ - [ ] Security review (auth, upload limits, path traversal)
432
+ - [ ] Deploy checklist for `satdetect-dev`
433
+
434
+ **Total estimate:** ~25–35 working days (1 senior dev), assuming DDA provides sample GeoTIFFs and hierarchy taxonomy early.
435
+
436
+ ---
437
+
438
+ ## 10. Hugging Face dev constraints & mitigations
439
+
440
+ | Constraint | Impact | Mitigation |
441
+ |------------|--------|------------|
442
+ | ~60s HTTP timeout (cpu-basic) | Sync detect fails on large TIFF | Async jobs + immediate 202 response; detection on downsampled preview |
443
+ | Ephemeral disk | Library loss on rebuild | HF persistent storage volume; document backup to S3 optional |
444
+ | No GPU | Slow DL | Keep AdaptFormer on CPU tiles; cap image size; queue one job at a time |
445
+ | 50 GB space limit | Large GeoTIFF library | Per-file quota; admin purge; external blob store Phase 2 |
446
+ | Single worker | No true queue | SQLite job lock; one `running` job at a time |
447
+
448
+ **Dockerfile (dev):** Switch base to `osgeo/gdal:ubuntu-small` or install `libgdal` in existing image.
449
+
450
+ ---
451
+
452
+ ## 11. File / module layout (new code)
453
+
454
+ ```text
455
+ app/
456
+ ├── dda/
457
+ │ ├── __init__.py
458
+ │ ├── config.py # APP_MODE, limits, DEPT_API_*
459
+ │ ├── models_library.py # ImageAsset, hierarchy ORMs
460
+ │ ├── models_jobs.py # DetectionJob, RegionReview, ExportBatch
461
+ │ ├── geotiff_io.py # rasterio ingest, reproject, preview
462
+ │ ├── geo_regions.py # pixel→lat/lng, area m²
463
+ │ ├── change_type_map.py # internal → DDA types
464
+ │ ├── job_runner.py # async detection worker
465
+ │ ├── library_routes.py # FR-01, FR-02 API
466
+ │ ├── jobs_routes.py # FR-04 API
467
+ │ ├── reports_routes.py # FR-05, PDF
468
+ │ ├── review_routes.py # FR-08
469
+ │ └── dept_export.py # adapter pattern
470
+ ├── static/js/dda/
471
+ │ ├── library.js
472
+ │ ├── comparison.js
473
+ │ ├── jobs.js
474
+ │ └── review.js
475
+ └── templates/
476
+ ├── index.html # legacy (production)
477
+ └── index_dda.html # dev SPA shell
478
+ ```
479
+
480
+ Wire routers in `main.py`:
481
+
482
+ ```python
483
+ if os.getenv("APP_MODE") == "dda":
484
+ app.include_router(library_routes.router, prefix="/api/dda")
485
+ ...
486
+ ```
487
+
488
+ Set on **`satdetect-dev`** Space: `APP_MODE=dda`.
489
+ Leave **`satdetect`** unset → legacy behavior.
490
+
491
+ ---
492
+
493
+ ## 12. Testing strategy
494
+
495
+ | Layer | Tests |
496
+ |-------|-------|
497
+ | GeoTIFF ingest | Unit: georef / no-georef / 1-band / 4-band samples |
498
+ | Pair validation | CRS mismatch, non-overlap |
499
+ | Job lifecycle | queued → running → completed/failed |
500
+ | lat/lng accuracy | Known corner coords → centroid within tolerance |
501
+ | DDA type mapping | Snapshot tests on classified regions |
502
+ | RBAC | Role forbidden on upload/delete |
503
+ | PDF | Report renders all mandatory fields |
504
+ | E2E | Upload 2 TIFFs → job → report → confirm → CSV export |
505
+
506
+ Use `scripts/validate_detection.py` extended with GeoTIFF fixtures.
507
+
508
+ ---
509
+
510
+ ## 13. Risks & dependencies
511
+
512
+ | Risk | Owner | Mitigation |
513
+ |------|-------|------------|
514
+ | DDA GeoTIFF quality / GSD | DDA (per SOW) | Pre-upload validation messages |
515
+ | No departmental API spec | DDA IT | File export fallback (FR-08) |
516
+ | Area taxonomy not finalized | DDA | Configurable hierarchy admin UI |
517
+ | Model accuracy on drone imagery | Shared | Alignment warnings; false-positive review loop |
518
+ | HF CPU timeout | Engineering | Async jobs + downsample policy |
519
+
520
+ **Blocked until DDA provides:**
521
+ 1. Sample GeoTIFF pairs (georeferenced + one without georef)
522
+ 2. Zone/Village/Area master list (or confirm Delhi list)
523
+ 3. Department API spec OR confirm export-only
524
+ 4. Grid definition if grid mode required at launch
525
+
526
+ ---
527
+
528
+ ## 14. Out of scope (explicit)
529
+
530
+ - Training/fine-tuning new DL models (use existing AdaptFormer + review feedback archive for Phase 2)
531
+ - Full GIS desktop analysis (QGIS replacement)
532
+ - Real-time satellite ingestion feeds
533
+ - Mobile native apps
534
+ - Production deployment of DDA features (until UAT sign-off)
535
+
536
+ ---
537
+
538
+ ## 15. Immediate next steps
539
+
540
+ 1. **Create `satdetect-dev` Space env:** `APP_MODE=dda`
541
+ 2. **Phase 0 PR** to `master` → `git push hf-dev master:main`
542
+ 3. **Request from DDA:** 3 sample GeoTIFF pairs + hierarchy spreadsheet
543
+ 4. **Implement Phase 1** (library + upload) — highest dependency for all other FRs
544
+
545
+ ---
546
+
547
+ ## Appendix A — Legacy endpoint compatibility
548
+
549
+ Keep existing endpoints working in legacy mode:
550
+
551
+ | Legacy | DDA equivalent |
552
+ |--------|----------------|
553
+ | `POST /api/detect` (multipart files) | `POST /api/dda/jobs` (library IDs) |
554
+ | `GET /api/history` | `GET /api/dda/reports` |
555
+ | Guest user | RBAC users with roles |
556
+
557
+ No breaking changes to production API contract.
558
+
559
+ ---
560
+
561
+ ## Appendix B — Sample job response
562
+
563
+ ```json
564
+ {
565
+ "jobId": 42,
566
+ "status": "completed",
567
+ "runId": 108,
568
+ "reportUrl": "/api/dda/reports/108",
569
+ "summary": {
570
+ "regionsCount": 7,
571
+ "changePercentage": 2.34,
572
+ "byType": {
573
+ "New Construction": 3,
574
+ "Vegetation Change": 2,
575
+ "Other": 2
576
+ }
577
+ }
578
+ }
579
+ ```
580
+
581
+ ---
582
+
583
+ *Plan version 1.0 — derived from **Scope of Work_Change Detection-For Team.docx** (FR-01 through FR-08).*
docs/sow_extracted.txt ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Scope of Work
2
+ The System will provide a simple workflow for change detection over satellite / drone imagery, covering the following:
3
+ A centralized image library organized by Zone, Village, Area and Year (e.g., 2025, 2026), or on a Grid basis.
4
+ Upload of satellite and drone images in TIF / TIFF format.
5
+ A drag-and-drop interface to select two images (Base image and Comparison image) for analysis.
6
+ AI-based change detection using pre-trained deep learning models.
7
+ Notification to the end user along with a change detection report.
8
+ Navigation to change locations based on latitude / longitude.
9
+ Clickable change regions in the report, which pan the image viewer to the corresponding area.
10
+ Typical use cases: monitoring of unauthorized construction, encroachment detection and land-use change monitoring.
11
+ Functional Requirements
12
+ All requirements below are Mandatory unless stated otherwise.
13
+ FR-01: Centralized Image Library
14
+ Requirement ID
15
+ FR-01
16
+ Title
17
+ Centralized Satellite / Drone Image Library
18
+ Description
19
+ The System shall provide a centralized library to store, organize and search satellite and drone images, organized by Zone, Village, Area and Year, or on a Grid basis.
20
+ Hierarchical organization: Zone → Village → Area → Year, maintained through image metadata.
21
+ Basic metadata per image: image ID, capture date, source (satellite / drone), format and location reference.
22
+ Browse (tree view) and search / filter by zone, village, area, year or capture date.
23
+ Role-based access for viewing and uploading images.
24
+ Providing images of suitable size and resolution for change detection shall be the responsibility of DDA.
25
+ FR-02: Upload of Images
26
+ Requirement ID
27
+ FR-02
28
+ Title
29
+ Upload of Satellite / Drone Images
30
+ Description
31
+ The System shall allow authorized users to upload satellite and drone images in TIF / TIFF format with basic validation.
32
+ Supported format: GeoTIFF (.tif / .tiff).
33
+ Where georeferencing is not embedded, the user shall provide location reference and capture date before the image is saved.
34
+ Upload success / failure status shall be shown to the user.
35
+ FR-03: Selection of Two Images for Comparison
36
+ Requirement ID
37
+ FR-03
38
+ Title
39
+ Drag-and-Drop Selection of Two Images
40
+ Description
41
+ The System shall provide a drag-and-drop interface to select a Base (older) image and a Comparison (newer) image for change detection.
42
+ Two clearly labelled drop targets: Base Image (T1) and Comparison Image (T2).
43
+ Images can be dragged from the library or selected through a picker dialog.
44
+ Thumbnail preview and capture dates of both images shown before analysis is started.
45
+ FR-04: AI-Based Change Detection
46
+ Requirement ID
47
+ FR-04
48
+ Title
49
+ AI-Based Change Detection Using Pre-Trained Models
50
+ Description
51
+ The System shall run change detection on the selected image pair using pre-trained deep learning models, subject to the AI accuracy note above.
52
+ Detected changes shall be shown as change regions with change type, approximate area and location (latitude / longitude) along with a model confidence score.
53
+ Indicative change types: New Construction, Demolition, Extension, Vegetation Change, Other. The list may be refined during implementation.
54
+ Detection runs as a background job; the user is informed when it completes.
55
+ Output accuracy depends on image quality and model training maturity, as described in the AI accuracy note.
56
+ FR-05: Notification and Change Detection Report
57
+ Requirement ID
58
+ FR-05
59
+ Title
60
+ Notification with Change Detection Report
61
+ Description
62
+ On completion of analysis, the System shall notify the user and provide a change detection report.
63
+ In-application notification (email optional) with a link to the report.
64
+ Report viewable in the browser and exportable to PDF.
65
+ Reports remain available in the System for later reference.
66
+ FR-06: Geo-Navigation to Change Locations
67
+ Requirement ID
68
+ FR-06
69
+ Title
70
+ Geo-Navigation Based on Latitude / Longitude
71
+ Description
72
+ The System shall allow the user to navigate to the location where a change has been found, based on its latitude / longitude.
73
+ Each change record displays its coordinates in the report.
74
+ A Locate action zooms / pans the viewer to that change location.
75
+ FR-07: Clickable Change Regions
76
+ Requirement ID
77
+ FR-07
78
+ Title
79
+ Clickable Change Types / Regions with Auto-Pan
80
+ Description
81
+ Change regions in the report shall be clickable; on click, the viewer shall pan and zoom to the corresponding area of the image.
82
+ Clicking a change record pans and highlights that region in the viewer.
83
+ Side-by-side view of Base and Comparison images with the change overlay.
84
+ FR-08: Entry of Confirmed Changes into the Departmental System
85
+ Requirement ID
86
+ FR-08
87
+ Title
88
+ Direct Entry of Reported Changes
89
+ Description
90
+ After review, the user shall be able to enter confirmed changes from the report into the departmental system for further action.
91
+ The user verifies each change (Confirmed / False Positive) before submission; only confirmed changes are submitted.
92
+ Submission via API where the departmental system provides one; otherwise a structured export (Excel / CSV / PDF) shall be generated for manual entry.
93
+ False-positive markings are retained and used for future model training and improvement.
requirements.txt CHANGED
@@ -15,3 +15,5 @@ numpy>=1.24.0
15
  opencv-python-headless>=4.8.0
16
  scikit-learn>=1.3.0
17
  requests>=2.28.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
scripts/push_hf_dev.ps1 CHANGED
@@ -14,5 +14,16 @@ if ($branch -ne "master") {
14
  }
15
 
16
  Write-Host "Pushing master -> hf-dev/main (satdetect-dev)..."
17
- git push hf-dev master:main
 
 
 
 
 
 
 
 
 
 
 
18
  Write-Host "Done. Dev app: https://huggingface.co/spaces/coderuday21/satdetect-dev"
 
14
  }
15
 
16
  Write-Host "Pushing master -> hf-dev/main (satdetect-dev)..."
17
+ $force = $args -contains "-Force" -or $args -contains "--force"
18
+ if ($force) {
19
+ git push hf-dev master:main --force
20
+ } else {
21
+ git push hf-dev master:main
22
+ if ($LASTEXITCODE -ne 0) {
23
+ Write-Host ""
24
+ Write-Host "Push rejected? Hugging Face may have a starter README commit."
25
+ Write-Host "Re-run with: .\scripts\push_hf_dev.ps1 -Force"
26
+ exit $LASTEXITCODE
27
+ }
28
+ }
29
  Write-Host "Done. Dev app: https://huggingface.co/spaces/coderuday21/satdetect-dev"
static/css/dda.css ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* DDA dev UI — extends style.css */
2
+
3
+ .dda-app { max-width: 1400px; margin: 0 auto; padding: 1rem 1.25rem 2rem; }
4
+
5
+ .dda-header {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ justify-content: space-between;
10
+ gap: 1rem;
11
+ margin-bottom: 1.25rem;
12
+ }
13
+ .dda-badge {
14
+ font-size: 0.7rem;
15
+ font-weight: 600;
16
+ text-transform: uppercase;
17
+ letter-spacing: 0.04em;
18
+ padding: 0.15rem 0.45rem;
19
+ border-radius: 4px;
20
+ background: rgba(45, 212, 191, 0.15);
21
+ color: var(--grad-start);
22
+ margin-left: 0.5rem;
23
+ }
24
+ .dda-tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; }
25
+ .dda-tab {
26
+ padding: 0.45rem 0.9rem;
27
+ border: 1px solid var(--border);
28
+ border-radius: 8px;
29
+ background: var(--bg-elevated);
30
+ color: var(--text-muted);
31
+ cursor: pointer;
32
+ font-size: 0.88rem;
33
+ transition: all var(--transition);
34
+ }
35
+ .dda-tab:hover { border-color: var(--grad-start); color: var(--text); }
36
+ .dda-tab.active {
37
+ background: linear-gradient(135deg, rgba(45,212,191,0.12), rgba(16,185,129,0.08));
38
+ border-color: var(--grad-start);
39
+ color: var(--grad-start);
40
+ font-weight: 600;
41
+ }
42
+
43
+ .dda-panel { display: none; }
44
+ .dda-panel.active { display: block; }
45
+
46
+ .dda-layout {
47
+ display: grid;
48
+ grid-template-columns: minmax(220px, 280px) 1fr;
49
+ gap: 1rem;
50
+ align-items: start;
51
+ }
52
+ @media (max-width: 900px) {
53
+ .dda-layout { grid-template-columns: 1fr; }
54
+ }
55
+
56
+ .dda-sidebar { position: sticky; top: 1rem; max-height: 70vh; overflow: auto; }
57
+ .dda-search {
58
+ width: 100%;
59
+ margin-bottom: 0.75rem;
60
+ padding: 0.45rem 0.65rem;
61
+ border: 1px solid var(--border);
62
+ border-radius: 6px;
63
+ background: var(--bg);
64
+ color: var(--text);
65
+ font-size: 0.88rem;
66
+ }
67
+ .dda-tree { font-size: 0.88rem; }
68
+ .dda-tree-zone { margin-bottom: 0.5rem; }
69
+ .dda-tree-zone > button {
70
+ width: 100%;
71
+ text-align: left;
72
+ padding: 0.35rem 0.5rem;
73
+ border: none;
74
+ background: transparent;
75
+ color: var(--text);
76
+ cursor: pointer;
77
+ border-radius: 4px;
78
+ font-weight: 600;
79
+ }
80
+ .dda-tree-zone > button:hover { background: var(--bg-hover); }
81
+ .dda-tree-villages { padding-left: 0.75rem; display: none; }
82
+ .dda-tree-zone.open .dda-tree-villages { display: block; }
83
+ .dda-tree-village {
84
+ display: block;
85
+ width: 100%;
86
+ text-align: left;
87
+ padding: 0.3rem 0.5rem;
88
+ border: none;
89
+ background: transparent;
90
+ color: var(--text-muted);
91
+ cursor: pointer;
92
+ border-radius: 4px;
93
+ font-size: 0.85rem;
94
+ }
95
+ .dda-tree-village:hover, .dda-tree-village.active {
96
+ background: var(--bg-hover);
97
+ color: var(--grad-start);
98
+ }
99
+
100
+ .dda-grid {
101
+ display: grid;
102
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
103
+ gap: 0.75rem;
104
+ min-height: 80px;
105
+ }
106
+ .dda-card-img {
107
+ border: 1px solid var(--border);
108
+ border-radius: 8px;
109
+ overflow: hidden;
110
+ background: var(--bg-elevated);
111
+ cursor: grab;
112
+ transition: border-color var(--transition);
113
+ }
114
+ .dda-card-img:hover { border-color: var(--grad-start); }
115
+ .dda-card-img img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
116
+ .dda-card-img .meta {
117
+ padding: 0.4rem 0.5rem;
118
+ font-size: 0.75rem;
119
+ color: var(--text-muted);
120
+ line-height: 1.3;
121
+ }
122
+
123
+ .dda-compare-slots {
124
+ display: grid;
125
+ grid-template-columns: 1fr 1fr;
126
+ gap: 1rem;
127
+ margin: 1rem 0;
128
+ }
129
+ .dda-slot {
130
+ border: 2px dashed var(--border);
131
+ border-radius: 10px;
132
+ padding: 1.5rem;
133
+ min-height: 140px;
134
+ text-align: center;
135
+ }
136
+ .dda-slot.filled { border-style: solid; border-color: var(--grad-start); }
137
+ .dda-slot-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
138
+
139
+ .dda-upload-form .location-row { margin-bottom: 0.5rem; }
static/js/dda/app.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API = '';
2
+
3
+ async function ddaApi(method, path, options = {}) {
4
+ const headers = { ...options.headers };
5
+ if (options.body && !(options.body instanceof FormData)) {
6
+ headers['Content-Type'] = 'application/json';
7
+ }
8
+ const res = await fetch(API + path, { method, headers, credentials: 'include', ...options });
9
+ const text = await res.text();
10
+ let data = null;
11
+ try { data = text ? JSON.parse(text) : null; } catch (_) {}
12
+ if (!res.ok) throw new Error(data?.detail || res.statusText || 'Request failed');
13
+ return data;
14
+ }
15
+
16
+ function showDdaError(msg) {
17
+ const el = document.getElementById('dda-error');
18
+ if (!el) return;
19
+ el.textContent = msg;
20
+ el.classList.remove('hidden');
21
+ }
22
+ function hideDdaError() {
23
+ document.getElementById('dda-error')?.classList.add('hidden');
24
+ }
25
+ function showDdaSuccess(msg) {
26
+ const el = document.getElementById('dda-success');
27
+ if (!el) return;
28
+ el.textContent = msg;
29
+ el.classList.remove('hidden');
30
+ setTimeout(() => el.classList.add('hidden'), 4000);
31
+ }
32
+
33
+ let ddaConfig = null;
34
+ let ddaHierarchy = null;
35
+ let selectedVillageId = null;
36
+ let selectedZoneId = null;
37
+
38
+ window.ddaState = {
39
+ get hierarchy() { return ddaHierarchy; },
40
+ get config() { return ddaConfig; },
41
+ get selectedVillageId() { return selectedVillageId; },
42
+ get selectedZoneId() { return selectedZoneId; },
43
+ setSelection(zoneId, villageId) {
44
+ selectedZoneId = zoneId;
45
+ selectedVillageId = villageId;
46
+ },
47
+ refreshImages: () => loadLibraryImages(),
48
+ };
49
+
50
+ document.querySelectorAll('.dda-tab').forEach((btn) => {
51
+ btn.addEventListener('click', () => {
52
+ document.querySelectorAll('.dda-tab').forEach((b) => b.classList.remove('active'));
53
+ document.querySelectorAll('.dda-panel').forEach((p) => p.classList.remove('active'));
54
+ btn.classList.add('active');
55
+ const tab = btn.dataset.tab;
56
+ document.getElementById('tab-' + tab)?.classList.add('active');
57
+ });
58
+ });
59
+
60
+ async function initDda() {
61
+ hideDdaError();
62
+ try {
63
+ ddaConfig = await ddaApi('GET', '/api/dda/config');
64
+ const hint = document.getElementById('lib-config-hint');
65
+ if (hint) {
66
+ hint.textContent = `Max ${ddaConfig.maxUploadMb} MB · GeoTIFF: ${ddaConfig.geotiffEnabled ? 'yes' : 'limited'}`;
67
+ }
68
+ ddaHierarchy = await ddaApi('GET', '/api/dda/hierarchy');
69
+ if (typeof renderHierarchy === 'function') renderHierarchy(ddaHierarchy);
70
+ if (typeof populateUploadSelects === 'function') populateUploadSelects(ddaHierarchy);
71
+ await loadLibraryImages();
72
+ } catch (err) {
73
+ showDdaError(err.message || 'Failed to load DDA configuration');
74
+ }
75
+ }
76
+
77
+ async function loadLibraryImages() {
78
+ const grid = document.getElementById('lib-grid');
79
+ if (!grid) return;
80
+ const q = document.getElementById('lib-filter')?.value?.trim() || '';
81
+ const params = new URLSearchParams();
82
+ if (selectedVillageId) params.set('village_id', String(selectedVillageId));
83
+ else if (selectedZoneId) params.set('zone_id', String(selectedZoneId));
84
+ if (q) params.set('q', q);
85
+ try {
86
+ const items = await ddaApi('GET', '/api/dda/images?' + params.toString());
87
+ if (!items.length) {
88
+ grid.innerHTML = '<p class="dim">No images yet. Upload a GeoTIFF or image above.</p>';
89
+ return;
90
+ }
91
+ grid.innerHTML = items.map((img) => `
92
+ <div class="dda-card-img" draggable="true" data-image-id="${img.id}" title="${img.originalFilename}">
93
+ ${img.thumbUrl ? `<img src="${img.thumbUrl}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
94
+ <div class="meta">
95
+ <strong>${img.year || '—'}</strong><br/>
96
+ ${img.captureDate || ''}<br/>
97
+ ${img.villageName || img.zoneName || ''}
98
+ </div>
99
+ </div>`).join('');
100
+ grid.querySelectorAll('.dda-card-img').forEach((card) => {
101
+ card.addEventListener('dragstart', (e) => {
102
+ e.dataTransfer.setData('text/plain', card.dataset.imageId);
103
+ });
104
+ });
105
+ } catch (err) {
106
+ grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
107
+ }
108
+ }
109
+
110
+ document.getElementById('lib-filter')?.addEventListener('input', () => {
111
+ clearTimeout(window._libFilterTimer);
112
+ window._libFilterTimer = setTimeout(loadLibraryImages, 300);
113
+ });
114
+
115
+ initDda();
static/js/dda/library.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function renderHierarchy(data) {
2
+ const tree = document.getElementById('lib-tree');
3
+ if (!tree || !data?.zones) return;
4
+ const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
5
+
6
+ tree.innerHTML = data.zones
7
+ .filter((z) => !filter || z.name.toLowerCase().includes(filter) ||
8
+ z.villages.some((v) => v.name.toLowerCase().includes(filter)))
9
+ .map((zone) => `
10
+ <div class="dda-tree-zone" data-zone-id="${zone.id}">
11
+ <button type="button" class="dda-zone-toggle">${zone.name}</button>
12
+ <div class="dda-tree-villages">
13
+ ${zone.villages
14
+ .filter((v) => !filter || v.name.toLowerCase().includes(filter) || zone.name.toLowerCase().includes(filter))
15
+ .map((v) => `
16
+ <button type="button" class="dda-tree-village" data-zone-id="${zone.id}" data-village-id="${v.id}">
17
+ ${v.name}${v.imageCount ? ` (${v.imageCount})` : ''}
18
+ </button>`).join('')}
19
+ </div>
20
+ </div>`).join('');
21
+
22
+ tree.querySelectorAll('.dda-zone-toggle').forEach((btn) => {
23
+ btn.addEventListener('click', () => {
24
+ const zoneEl = btn.closest('.dda-tree-zone');
25
+ zoneEl?.classList.toggle('open');
26
+ const zoneId = parseInt(zoneEl?.dataset.zoneId, 10);
27
+ window.ddaState.setSelection(zoneId, null);
28
+ window.ddaState.refreshImages();
29
+ });
30
+ });
31
+
32
+ tree.querySelectorAll('.dda-tree-village').forEach((btn) => {
33
+ btn.addEventListener('click', () => {
34
+ tree.querySelectorAll('.dda-tree-village').forEach((b) => b.classList.remove('active'));
35
+ btn.classList.add('active');
36
+ btn.closest('.dda-tree-zone')?.classList.add('open');
37
+ window.ddaState.setSelection(
38
+ parseInt(btn.dataset.zoneId, 10),
39
+ parseInt(btn.dataset.villageId, 10),
40
+ );
41
+ window.ddaState.refreshImages();
42
+ });
43
+ });
44
+ }
45
+
46
+ document.getElementById('lib-tree-search')?.addEventListener('input', () => {
47
+ if (window.ddaState?.hierarchy) renderHierarchy(window.ddaState.hierarchy);
48
+ });
49
+
50
+ function populateUploadSelects(data) {
51
+ const zoneSel = document.getElementById('up-zone');
52
+ const villageSel = document.getElementById('up-village');
53
+ if (!zoneSel || !villageSel || !data?.zones) return;
54
+
55
+ zoneSel.innerHTML = '<option value="">— Select —</option>';
56
+ data.zones.forEach((z) => {
57
+ const opt = document.createElement('option');
58
+ opt.value = z.id;
59
+ opt.textContent = z.name;
60
+ zoneSel.appendChild(opt);
61
+ });
62
+
63
+ if (!zoneSel.dataset.bound) {
64
+ zoneSel.dataset.bound = '1';
65
+ zoneSel.addEventListener('change', () => {
66
+ const hierarchy = window.ddaState?.hierarchy;
67
+ const zid = parseInt(zoneSel.value, 10);
68
+ villageSel.innerHTML = '<option value="">— Select —</option>';
69
+ villageSel.disabled = !zid;
70
+ if (!zid || !hierarchy) return;
71
+ const zone = hierarchy.zones.find((z) => z.id === zid);
72
+ (zone?.villages || []).forEach((v) => {
73
+ const opt = document.createElement('option');
74
+ opt.value = v.id;
75
+ opt.textContent = v.name;
76
+ villageSel.appendChild(opt);
77
+ });
78
+ });
79
+ }
80
+ }
81
+
82
+ document.getElementById('form-upload')?.addEventListener('submit', async (e) => {
83
+ e.preventDefault();
84
+ hideDdaError?.();
85
+ const fileInput = document.getElementById('up-file');
86
+ const file = fileInput?.files?.[0];
87
+ if (!file) {
88
+ showDdaError?.('Select a file to upload.');
89
+ return;
90
+ }
91
+
92
+ const form = new FormData();
93
+ form.append('file', file);
94
+ form.append('zone_id', document.getElementById('up-zone').value);
95
+ form.append('village_id', document.getElementById('up-village').value);
96
+ form.append('area_name', document.getElementById('up-area').value || '');
97
+ form.append('year', document.getElementById('up-year').value);
98
+ form.append('capture_date', document.getElementById('up-date').value);
99
+ form.append('source', document.getElementById('up-source').value);
100
+ form.append('manual_bounds_json', document.getElementById('up-manual-bounds').value || '');
101
+
102
+ const btn = document.getElementById('btn-upload');
103
+ btn.disabled = true;
104
+ btn.textContent = 'Uploading…';
105
+ try {
106
+ const data = await ddaApi('POST', '/api/dda/images/upload', { body: form });
107
+ showDdaSuccess?.(data?.status === 'success' ? 'Image uploaded to library.' : 'Upload complete.');
108
+ document.getElementById('form-upload').reset();
109
+ document.getElementById('up-year').value = '2025';
110
+ fileInput.value = '';
111
+ window.ddaState.hierarchy = await ddaApi('GET', '/api/dda/hierarchy');
112
+ renderHierarchy(window.ddaState.hierarchy);
113
+ populateUploadSelects(window.ddaState.hierarchy);
114
+ await window.ddaState.refreshImages();
115
+ } catch (err) {
116
+ showDdaError?.(err.message || 'Upload failed');
117
+ } finally {
118
+ btn.disabled = false;
119
+ btn.textContent = 'Upload to Library';
120
+ }
121
+ });
122
+
123
+ // Default capture date = today
124
+ const dateInput = document.getElementById('up-date');
125
+ if (dateInput && !dateInput.value) {
126
+ dateInput.value = new Date().toISOString().slice(0, 10);
127
+ }
templates/index_dda.html ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>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=1" />
9
+ </head>
10
+ <body>
11
+ <div class="app dda-app">
12
+ <header class="dda-header">
13
+ <div class="app-brand">
14
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
15
+ <span>DDA Change Detection</span>
16
+ <span class="dda-badge">Dev</span>
17
+ </div>
18
+ <nav class="dda-tabs" role="tablist">
19
+ <button type="button" class="dda-tab active" data-tab="library" role="tab">Image Library</button>
20
+ <button type="button" class="dda-tab" data-tab="detect" role="tab">Change Detection</button>
21
+ <button type="button" class="dda-tab" data-tab="reports" role="tab">Reports</button>
22
+ </nav>
23
+ </header>
24
+
25
+ <div id="dda-error" class="alert alert-error hidden"></div>
26
+ <div id="dda-success" class="alert alert-success hidden"></div>
27
+
28
+ <!-- Tab: Image Library (FR-01, FR-02) -->
29
+ <section id="tab-library" class="dda-panel active" role="tabpanel">
30
+ <div class="dda-layout">
31
+ <aside class="dda-sidebar card">
32
+ <div class="card-header"><h3>Hierarchy</h3></div>
33
+ <input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter zones…" />
34
+ <div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
35
+ </aside>
36
+ <main class="dda-main">
37
+ <div class="card">
38
+ <div class="card-header">
39
+ <h3>Upload Image</h3>
40
+ <span class="dim" id="lib-config-hint"></span>
41
+ </div>
42
+ <form id="form-upload" class="dda-upload-form">
43
+ <div class="location-row">
44
+ <div class="form-group">
45
+ <label for="up-zone">Zone</label>
46
+ <select id="up-zone" required><option value="">— Select —</option></select>
47
+ </div>
48
+ <div class="form-group">
49
+ <label for="up-village">Village / Area</label>
50
+ <select id="up-village" required disabled><option value="">— Select zone —</option></select>
51
+ </div>
52
+ <div class="form-group">
53
+ <label for="up-area">Area name</label>
54
+ <input type="text" id="up-area" placeholder="Optional sub-area" />
55
+ </div>
56
+ <div class="form-group">
57
+ <label for="up-year">Year</label>
58
+ <input type="number" id="up-year" required min="2000" max="2100" value="2025" />
59
+ </div>
60
+ </div>
61
+ <div class="location-row">
62
+ <div class="form-group">
63
+ <label for="up-date">Capture date</label>
64
+ <input type="date" id="up-date" required />
65
+ </div>
66
+ <div class="form-group">
67
+ <label for="up-source">Source</label>
68
+ <select id="up-source"><option value="satellite">Satellite</option><option value="drone">Drone</option></select>
69
+ </div>
70
+ <div class="form-group">
71
+ <label for="up-file">Image file</label>
72
+ <input type="file" id="up-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
73
+ </div>
74
+ </div>
75
+ <div class="form-group">
76
+ <label for="up-manual-bounds">Manual bounds (WGS84)</label>
77
+ <input type="text" id="up-manual-bounds" placeholder="west,south,east,north — required if GeoTIFF has no georef" />
78
+ </div>
79
+ <button type="submit" class="btn btn-primary" id="btn-upload">Upload to Library</button>
80
+ </form>
81
+ </div>
82
+ <div class="card">
83
+ <div class="card-header">
84
+ <h3>Library Images</h3>
85
+ <input type="search" id="lib-filter" class="dda-search" placeholder="Search…" />
86
+ </div>
87
+ <div id="lib-grid" class="dda-grid"><p class="dim">Select a village or upload an image.</p></div>
88
+ </div>
89
+ </main>
90
+ </div>
91
+ </section>
92
+
93
+ <!-- Tab: Change Detection (FR-03 placeholder — Phase 2) -->
94
+ <section id="tab-detect" class="dda-panel" role="tabpanel">
95
+ <div class="card">
96
+ <div class="card-header"><h3>Compare Images</h3></div>
97
+ <p class="sub">Select <strong>Base (T1)</strong> and <strong>Comparison (T2)</strong> from the library. Async job pipeline — Phase 2.</p>
98
+ <div class="dda-compare-slots">
99
+ <div class="dda-slot" id="slot-t1" data-slot="t1">
100
+ <span class="dda-slot-label">Base Image (T1)</span>
101
+ <p class="dim">Drag from library or click to pick</p>
102
+ </div>
103
+ <div class="dda-slot" id="slot-t2" data-slot="t2">
104
+ <span class="dda-slot-label">Comparison Image (T2)</span>
105
+ <p class="dim">Drag from library or click to pick</p>
106
+ </div>
107
+ </div>
108
+ <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection (coming Phase 2)</button>
109
+ </div>
110
+ </section>
111
+
112
+ <!-- Tab: Reports (FR-05 placeholder) -->
113
+ <section id="tab-reports" class="dda-panel" role="tabpanel">
114
+ <div class="card">
115
+ <div class="card-header"><h3>Detection Reports</h3></div>
116
+ <p class="dim">Completed detection reports and PDF export — Phase 4.</p>
117
+ <div id="reports-list"></div>
118
+ </div>
119
+ </section>
120
+ </div>
121
+
122
+ <script src="/static/js/dda/app.js?v=1"></script>
123
+ <script src="/static/js/dda/library.js?v=1"></script>
124
+ </body>
125
+ </html>