Spaces:
Sleeping
Sleeping
Commit ·
41d16b3
1
Parent(s): c506ed2
Phase 0 DDA: APP_MODE, image library API, GeoTIFF ingest, dev UI
Browse files- DEPLOYMENT.md +17 -6
- Dockerfile +4 -1
- app/dda/__init__.py +1 -0
- app/dda/bootstrap.py +42 -0
- app/dda/config.py +32 -0
- app/dda/geotiff_io.py +113 -0
- app/dda/library_routes.py +290 -0
- app/dda/models.py +90 -0
- app/dda/seed.py +30 -0
- app/dda/seed_data.py +60 -0
- app/main.py +17 -3
- docs/IMPLEMENTATION_PLAN_DDA.md +583 -0
- docs/sow_extracted.txt +93 -0
- requirements.txt +2 -0
- scripts/push_hf_dev.ps1 +12 -1
- static/css/dda.css +139 -0
- static/js/dda/app.js +115 -0
- static/js/dda/library.js +127 -0
- templates/index_dda.html +125 -0
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
|
| 106 |
```powershell
|
| 107 |
-
git push origin
|
| 108 |
```
|
| 109 |
|
| 110 |
-
|
| 111 |
|
| 112 |
```powershell
|
| 113 |
-
git
|
| 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 |
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|