Spaces:
Sleeping
Sleeping
Commit ·
4595db8
1
Parent(s): c9672b1
Use local library_sources year folders instead of web upload library
Browse files- DEPLOYMENT.md +1 -0
- Dockerfile +1 -1
- app/dda/bootstrap.py +5 -2
- app/dda/config.py +21 -1
- app/dda/library_routes.py +3 -1
- app/dda/local_library.py +160 -0
- app/dda/local_routes.py +85 -0
- library_sources/2024/.gitkeep +0 -0
- library_sources/2025/.gitkeep +0 -0
- library_sources/2026/.gitkeep +0 -0
- library_sources/README.md +36 -0
- static/css/dda.css +47 -1
- static/js/dda/app.js +58 -26
- static/js/dda/library.js +19 -155
- templates/index_dda.html +19 -54
DEPLOYMENT.md
CHANGED
|
@@ -133,6 +133,7 @@ Set these in each Space’s **Settings → Repository secrets / Variables** if n
|
|
| 133 |
| Variable | Purpose |
|
| 134 |
|----------|---------|
|
| 135 |
| `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
|
|
|
|
| 136 |
| `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **2048** = 2 GB on dev) |
|
| 137 |
| `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
|
| 138 |
| `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
|
|
|
|
| 133 |
| Variable | Purpose |
|
| 134 |
|----------|---------|
|
| 135 |
| `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables DDA library UI) |
|
| 136 |
+
| `LOCAL_LIBRARY_ROOT` | Path to year folders (default: `library_sources/` in project) |
|
| 137 |
| `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **2048** = 2 GB on dev) |
|
| 138 |
| `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
|
| 139 |
| `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
|
Dockerfile
CHANGED
|
@@ -21,7 +21,7 @@ WORKDIR /app
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
-
ARG APP_BUILD=
|
| 25 |
ENV APP_BUILD=${APP_BUILD}
|
| 26 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
| 27 |
RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
|
|
|
|
| 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=27
|
| 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
|
app/dda/bootstrap.py
CHANGED
|
@@ -4,8 +4,9 @@ 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__)
|
|
@@ -16,6 +17,7 @@ def init_dda_database():
|
|
| 16 |
if not IS_DDA_MODE:
|
| 17 |
return
|
| 18 |
ensure_library_dirs()
|
|
|
|
| 19 |
try:
|
| 20 |
with engine.connect() as conn:
|
| 21 |
try:
|
|
@@ -39,4 +41,5 @@ def setup_dda(app: FastAPI) -> None:
|
|
| 39 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 40 |
return
|
| 41 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 42 |
-
|
|
|
|
|
|
| 4 |
from sqlalchemy import text as sa_text
|
| 5 |
|
| 6 |
from ..database import engine
|
| 7 |
+
from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders
|
| 8 |
from .library_routes import router as library_router
|
| 9 |
+
from .local_routes import router as local_router
|
| 10 |
from .seed import seed_delhi_hierarchy
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
|
|
|
| 17 |
if not IS_DDA_MODE:
|
| 18 |
return
|
| 19 |
ensure_library_dirs()
|
| 20 |
+
ensure_local_year_folders()
|
| 21 |
try:
|
| 22 |
with engine.connect() as conn:
|
| 23 |
try:
|
|
|
|
| 41 |
logger.info("APP_MODE=legacy — DDA routes disabled")
|
| 42 |
return
|
| 43 |
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 44 |
+
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 45 |
+
logger.info("APP_MODE=dda — DDA routes enabled (local folder library + legacy upload API)")
|
app/dda/config.py
CHANGED
|
@@ -6,9 +6,18 @@ from ..database import DATA_DIR
|
|
| 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 library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
|
| 14 |
MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
|
|
@@ -20,13 +29,24 @@ ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
|
| 20 |
|
| 21 |
|
| 22 |
def ensure_library_dirs() -> None:
|
| 23 |
-
for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR):
|
| 24 |
try:
|
| 25 |
d.mkdir(parents=True, exist_ok=True)
|
| 26 |
except OSError:
|
| 27 |
pass
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def max_upload_bytes_for_extension(ext: str) -> int:
|
| 31 |
if ext in (".tif", ".tiff"):
|
| 32 |
return MAX_GEOTIFF_BYTES
|
|
|
|
| 6 |
APP_MODE = os.environ.get("APP_MODE", "legacy").strip().lower()
|
| 7 |
IS_DDA_MODE = APP_MODE == "dda"
|
| 8 |
|
| 9 |
+
# Project root: change_detection_webapp/
|
| 10 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 11 |
+
|
| 12 |
+
# Local folder library — drop images into year subfolders here (no web upload required)
|
| 13 |
+
LOCAL_LIBRARY_ROOT = Path(
|
| 14 |
+
os.environ.get("LOCAL_LIBRARY_ROOT", str(PROJECT_ROOT / "library_sources"))
|
| 15 |
+
).resolve()
|
| 16 |
+
|
| 17 |
LIBRARY_DIR = DATA_DIR / "library"
|
| 18 |
THUMBS_DIR = LIBRARY_DIR / "thumbs"
|
| 19 |
PREVIEWS_DIR = LIBRARY_DIR / "previews"
|
| 20 |
+
LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
|
| 21 |
|
| 22 |
# GeoTIFF library upload limit (default 2 GB; override with MAX_GEOTIFF_MB on HF dev Space)
|
| 23 |
MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", "2048")) * 1024 * 1024
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def ensure_library_dirs() -> None:
|
| 32 |
+
for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR, LOCAL_THUMB_CACHE, LOCAL_LIBRARY_ROOT):
|
| 33 |
try:
|
| 34 |
d.mkdir(parents=True, exist_ok=True)
|
| 35 |
except OSError:
|
| 36 |
pass
|
| 37 |
|
| 38 |
|
| 39 |
+
def ensure_local_year_folders() -> None:
|
| 40 |
+
"""Create default year folders under library_sources if missing."""
|
| 41 |
+
from datetime import datetime
|
| 42 |
+
current = datetime.now().year
|
| 43 |
+
for year in (current - 1, current, current + 1):
|
| 44 |
+
try:
|
| 45 |
+
(LOCAL_LIBRARY_ROOT / str(year)).mkdir(parents=True, exist_ok=True)
|
| 46 |
+
except OSError:
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
|
| 50 |
def max_upload_bytes_for_extension(ext: str) -> int:
|
| 51 |
if ext in (".tif", ".tiff"):
|
| 52 |
return MAX_GEOTIFF_BYTES
|
app/dda/library_routes.py
CHANGED
|
@@ -65,7 +65,7 @@ def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "
|
|
| 65 |
@router.get("/config")
|
| 66 |
def dda_config():
|
| 67 |
_require_dda()
|
| 68 |
-
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
@@ -76,6 +76,8 @@ def dda_config():
|
|
| 76 |
"geotiffEnabled": geotiff_io_available(),
|
| 77 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 78 |
"hierarchyMode": "admin",
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
|
|
|
|
| 65 |
@router.get("/config")
|
| 66 |
def dda_config():
|
| 67 |
_require_dda()
|
| 68 |
+
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, LOCAL_LIBRARY_ROOT
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
|
|
| 76 |
"geotiffEnabled": geotiff_io_available(),
|
| 77 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 78 |
"hierarchyMode": "admin",
|
| 79 |
+
"librarySource": "local_folder",
|
| 80 |
+
"localLibraryPath": str(LOCAL_LIBRARY_ROOT),
|
| 81 |
}
|
| 82 |
|
| 83 |
|
app/dda/local_library.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Read satellite/drone images from local year-based folders.
|
| 3 |
+
|
| 4 |
+
Folder layout (under library_sources/):
|
| 5 |
+
|
| 6 |
+
library_sources/
|
| 7 |
+
2024/
|
| 8 |
+
image_a.tif
|
| 9 |
+
site_1/
|
| 10 |
+
image_b.tif
|
| 11 |
+
2025/
|
| 12 |
+
image_c.tif
|
| 13 |
+
|
| 14 |
+
Copy or save files directly into the year folder on disk; the app scans on load / refresh.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import hashlib
|
| 19 |
+
import logging
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import List, Optional
|
| 23 |
+
|
| 24 |
+
from fastapi import HTTPException
|
| 25 |
+
|
| 26 |
+
from .config import ALLOWED_EXTENSIONS, LOCAL_LIBRARY_ROOT, LOCAL_THUMB_CACHE
|
| 27 |
+
from .geotiff_io import inspect_image, raster_to_preview_png
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class LocalImageEntry:
|
| 34 |
+
path: str # relative posix path, e.g. 2025/aerial.tif
|
| 35 |
+
year: int
|
| 36 |
+
filename: str
|
| 37 |
+
file_size_bytes: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _is_year_dir(name: str) -> bool:
|
| 41 |
+
return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def safe_resolve(relative_path: str) -> Path:
|
| 45 |
+
"""Resolve a library-relative path; block path traversal."""
|
| 46 |
+
rel = relative_path.replace("\\", "/").lstrip("/")
|
| 47 |
+
if not rel or ".." in rel.split("/"):
|
| 48 |
+
raise HTTPException(status_code=400, detail="Invalid image path")
|
| 49 |
+
root = LOCAL_LIBRARY_ROOT.resolve()
|
| 50 |
+
full = (root / rel).resolve()
|
| 51 |
+
try:
|
| 52 |
+
full.relative_to(root)
|
| 53 |
+
except ValueError:
|
| 54 |
+
raise HTTPException(status_code=400, detail="Invalid image path")
|
| 55 |
+
if not full.is_file():
|
| 56 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 57 |
+
if full.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 58 |
+
raise HTTPException(status_code=400, detail="Unsupported image format")
|
| 59 |
+
return full
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def scan_years() -> List[dict]:
|
| 63 |
+
"""List year folders and image counts."""
|
| 64 |
+
ensure_root()
|
| 65 |
+
years = []
|
| 66 |
+
if not LOCAL_LIBRARY_ROOT.exists():
|
| 67 |
+
return years
|
| 68 |
+
for entry in sorted(LOCAL_LIBRARY_ROOT.iterdir()):
|
| 69 |
+
if not entry.is_dir() or not _is_year_dir(entry.name):
|
| 70 |
+
continue
|
| 71 |
+
count = sum(
|
| 72 |
+
1
|
| 73 |
+
for p in entry.rglob("*")
|
| 74 |
+
if p.is_file() and p.suffix.lower() in ALLOWED_EXTENSIONS
|
| 75 |
+
)
|
| 76 |
+
years.append({"year": int(entry.name), "imageCount": count})
|
| 77 |
+
return years
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
|
| 81 |
+
"""Scan library_sources for images, optionally filtered by year and filename."""
|
| 82 |
+
ensure_root()
|
| 83 |
+
results: List[LocalImageEntry] = []
|
| 84 |
+
root = LOCAL_LIBRARY_ROOT
|
| 85 |
+
if not root.exists():
|
| 86 |
+
return results
|
| 87 |
+
|
| 88 |
+
q = (query or "").strip().lower()
|
| 89 |
+
year_dirs: List[Path]
|
| 90 |
+
if year is not None:
|
| 91 |
+
ydir = root / str(year)
|
| 92 |
+
year_dirs = [ydir] if ydir.is_dir() else []
|
| 93 |
+
else:
|
| 94 |
+
year_dirs = [d for d in sorted(root.iterdir()) if d.is_dir() and _is_year_dir(d.name)]
|
| 95 |
+
|
| 96 |
+
for ydir in year_dirs:
|
| 97 |
+
y = int(ydir.name)
|
| 98 |
+
for path in sorted(ydir.rglob("*")):
|
| 99 |
+
if not path.is_file() or path.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 100 |
+
continue
|
| 101 |
+
rel = path.relative_to(root).as_posix()
|
| 102 |
+
if q and q not in rel.lower():
|
| 103 |
+
continue
|
| 104 |
+
try:
|
| 105 |
+
size = path.stat().st_size
|
| 106 |
+
except OSError:
|
| 107 |
+
continue
|
| 108 |
+
results.append(
|
| 109 |
+
LocalImageEntry(
|
| 110 |
+
path=rel,
|
| 111 |
+
year=y,
|
| 112 |
+
filename=path.name,
|
| 113 |
+
file_size_bytes=size,
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
return results
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def entry_to_dict(entry: LocalImageEntry, include_meta: bool = False) -> dict:
|
| 120 |
+
out = {
|
| 121 |
+
"path": entry.path,
|
| 122 |
+
"year": entry.year,
|
| 123 |
+
"filename": entry.filename,
|
| 124 |
+
"fileSizeBytes": entry.file_size_bytes,
|
| 125 |
+
"thumbUrl": f"/api/dda/local/thumb?path={entry.path}",
|
| 126 |
+
"source": "local_folder",
|
| 127 |
+
}
|
| 128 |
+
if include_meta:
|
| 129 |
+
try:
|
| 130 |
+
full = safe_resolve(entry.path)
|
| 131 |
+
meta = inspect_image(full)
|
| 132 |
+
out.update({
|
| 133 |
+
"width": meta.width,
|
| 134 |
+
"height": meta.height,
|
| 135 |
+
"hasGeoref": meta.has_georef,
|
| 136 |
+
"format": meta.format,
|
| 137 |
+
})
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.warning("Metadata read failed for %s: %s", entry.path, exc)
|
| 140 |
+
return out
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def thumb_cache_path(relative_path: str) -> Path:
|
| 144 |
+
key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
|
| 145 |
+
return LOCAL_THUMB_CACHE / f"{key}.png"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 149 |
+
full = safe_resolve(relative_path)
|
| 150 |
+
cache = thumb_cache_path(relative_path)
|
| 151 |
+
if cache.exists():
|
| 152 |
+
return cache
|
| 153 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 154 |
+
raster_to_preview_png(full, cache, max_side=max_side)
|
| 155 |
+
return cache
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def ensure_root() -> None:
|
| 159 |
+
LOCAL_LIBRARY_ROOT.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
|
app/dda/local_routes.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API for reading images from local library_sources/ year folders."""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
+
|
| 7 |
+
from .config import IS_DDA_MODE, LOCAL_LIBRARY_ROOT, geotiff_io_available
|
| 8 |
+
from .local_library import (
|
| 9 |
+
entry_to_dict,
|
| 10 |
+
get_or_build_thumb,
|
| 11 |
+
safe_resolve,
|
| 12 |
+
scan_images,
|
| 13 |
+
scan_years,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _require_dda():
|
| 20 |
+
if not IS_DDA_MODE:
|
| 21 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/local/config")
|
| 25 |
+
def local_library_config():
|
| 26 |
+
_require_dda()
|
| 27 |
+
return {
|
| 28 |
+
"source": "local_folder",
|
| 29 |
+
"rootPath": str(LOCAL_LIBRARY_ROOT),
|
| 30 |
+
"instructions": (
|
| 31 |
+
"Copy .tif / .tiff images into year folders under library_sources/ "
|
| 32 |
+
"(e.g. library_sources/2025/my_image.tif), then click Refresh."
|
| 33 |
+
),
|
| 34 |
+
"geotiffEnabled": geotiff_io_available(),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/local/years")
|
| 39 |
+
def local_years():
|
| 40 |
+
_require_dda()
|
| 41 |
+
return {"years": scan_years(), "rootPath": str(LOCAL_LIBRARY_ROOT)}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/local/images")
|
| 45 |
+
def local_images(
|
| 46 |
+
year: Optional[int] = Query(None),
|
| 47 |
+
q: Optional[str] = Query(None),
|
| 48 |
+
):
|
| 49 |
+
_require_dda()
|
| 50 |
+
entries = scan_images(year=year, query=q)
|
| 51 |
+
return [entry_to_dict(e) for e in entries]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/local/images/detail")
|
| 55 |
+
def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
|
| 56 |
+
_require_dda()
|
| 57 |
+
entries = scan_images()
|
| 58 |
+
match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
|
| 59 |
+
if not match:
|
| 60 |
+
safe_resolve(path) # raises 404 if missing
|
| 61 |
+
entries = scan_images()
|
| 62 |
+
match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
|
| 63 |
+
if not match:
|
| 64 |
+
raise HTTPException(status_code=404, detail="Image not found in library scan")
|
| 65 |
+
return entry_to_dict(match, include_meta=True)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/local/thumb")
|
| 69 |
+
def local_thumb(path: str = Query(...)):
|
| 70 |
+
_require_dda()
|
| 71 |
+
try:
|
| 72 |
+
thumb = get_or_build_thumb(path)
|
| 73 |
+
return FileResponse(thumb, media_type="image/png")
|
| 74 |
+
except HTTPException:
|
| 75 |
+
raise
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
raise HTTPException(status_code=500, detail=f"Thumbnail failed: {exc}") from exc
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/local/rescan")
|
| 81 |
+
def local_rescan():
|
| 82 |
+
_require_dda()
|
| 83 |
+
years = scan_years()
|
| 84 |
+
total = sum(y["imageCount"] for y in years)
|
| 85 |
+
return {"ok": True, "years": years, "totalImages": total, "rootPath": str(LOCAL_LIBRARY_ROOT)}
|
library_sources/2024/.gitkeep
ADDED
|
File without changes
|
library_sources/2025/.gitkeep
ADDED
|
File without changes
|
library_sources/2026/.gitkeep
ADDED
|
File without changes
|
library_sources/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local Image Library
|
| 2 |
+
|
| 3 |
+
Place satellite / drone images here **by year**. The app reads directly from this folder — no web upload needed.
|
| 4 |
+
|
| 5 |
+
## Folder structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
library_sources/
|
| 9 |
+
2024/
|
| 10 |
+
your_image.tif
|
| 11 |
+
optional_subfolder/
|
| 12 |
+
another_image.tif
|
| 13 |
+
2025/
|
| 14 |
+
site_a.tif
|
| 15 |
+
2026/
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Supported formats
|
| 19 |
+
|
| 20 |
+
- `.tif` / `.tiff` (GeoTIFF — preferred)
|
| 21 |
+
- `.png`, `.jpg`, `.jpeg` (for testing)
|
| 22 |
+
|
| 23 |
+
## How to use
|
| 24 |
+
|
| 25 |
+
1. Copy your images into the correct **year** folder (e.g. `library_sources/2025/`).
|
| 26 |
+
2. Run the app (`python run.py` or open the dev Space).
|
| 27 |
+
3. Open **Image Library** → click **Refresh** if you added files while the app was running.
|
| 28 |
+
4. Select a year in the sidebar to view images.
|
| 29 |
+
|
| 30 |
+
## Large files
|
| 31 |
+
|
| 32 |
+
GeoTIFF files up to **2 GB** are supported when read from disk. Copy files via Explorer/Finder — much faster than browser upload.
|
| 33 |
+
|
| 34 |
+
## Custom location
|
| 35 |
+
|
| 36 |
+
Set environment variable `LOCAL_LIBRARY_ROOT` to use a different folder path.
|
static/css/dda.css
CHANGED
|
@@ -65,7 +65,53 @@
|
|
| 65 |
font-size: 0.88rem;
|
| 66 |
}
|
| 67 |
.dda-tree { font-size: 0.88rem; }
|
| 68 |
-
.dda-tree-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
.dda-tree-zone > button {
|
| 70 |
width: 100%;
|
| 71 |
text-align: left;
|
|
|
|
| 65 |
font-size: 0.88rem;
|
| 66 |
}
|
| 67 |
.dda-tree { font-size: 0.88rem; }
|
| 68 |
+
.dda-tree-year {
|
| 69 |
+
display: block;
|
| 70 |
+
width: 100%;
|
| 71 |
+
text-align: left;
|
| 72 |
+
padding: 0.4rem 0.55rem;
|
| 73 |
+
margin-bottom: 0.2rem;
|
| 74 |
+
border: none;
|
| 75 |
+
background: transparent;
|
| 76 |
+
color: var(--text-muted);
|
| 77 |
+
cursor: pointer;
|
| 78 |
+
border-radius: 6px;
|
| 79 |
+
font-size: 0.9rem;
|
| 80 |
+
font-weight: 500;
|
| 81 |
+
}
|
| 82 |
+
.dda-tree-year:hover, .dda-tree-year.active {
|
| 83 |
+
background: var(--bg-hover);
|
| 84 |
+
color: var(--grad-start);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.dda-folder-path {
|
| 88 |
+
font-size: 0.78rem;
|
| 89 |
+
margin-bottom: 0.6rem;
|
| 90 |
+
word-break: break-all;
|
| 91 |
+
}
|
| 92 |
+
.dda-path-code {
|
| 93 |
+
background: var(--bg);
|
| 94 |
+
border: 1px solid var(--border);
|
| 95 |
+
border-radius: 6px;
|
| 96 |
+
padding: 0.6rem 0.75rem;
|
| 97 |
+
font-size: 0.8rem;
|
| 98 |
+
overflow-x: auto;
|
| 99 |
+
margin: 0.5rem 0 0;
|
| 100 |
+
}
|
| 101 |
+
.dda-instructions .sub code {
|
| 102 |
+
font-size: 0.85em;
|
| 103 |
+
background: var(--bg);
|
| 104 |
+
padding: 0.1rem 0.35rem;
|
| 105 |
+
border-radius: 4px;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.card-header {
|
| 109 |
+
display: flex;
|
| 110 |
+
align-items: center;
|
| 111 |
+
justify-content: space-between;
|
| 112 |
+
gap: 0.5rem;
|
| 113 |
+
flex-wrap: wrap;
|
| 114 |
+
}
|
| 115 |
.dda-tree-zone > button {
|
| 116 |
width: 100%;
|
| 117 |
text-align: left;
|
static/js/dda/app.js
CHANGED
|
@@ -30,21 +30,23 @@ function showDdaSuccess(msg) {
|
|
| 30 |
setTimeout(() => el.classList.add('hidden'), 4000);
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
let ddaConfig = null;
|
| 34 |
-
let
|
| 35 |
-
let
|
| 36 |
-
let selectedZoneId = null;
|
| 37 |
|
| 38 |
window.ddaState = {
|
| 39 |
-
get hierarchy() { return ddaHierarchy; },
|
| 40 |
get config() { return ddaConfig; },
|
| 41 |
-
get
|
| 42 |
-
get
|
| 43 |
-
|
| 44 |
-
selectedZoneId = zoneId;
|
| 45 |
-
selectedVillageId = villageId;
|
| 46 |
-
},
|
| 47 |
refreshImages: () => loadLibraryImages(),
|
|
|
|
| 48 |
};
|
| 49 |
|
| 50 |
document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
@@ -57,51 +59,68 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
| 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 |
-
|
| 67 |
-
const label = gb >= 1 ? `${gb} GB GeoTIFF` : `${ddaConfig.maxGeotiffMb || ddaConfig.maxUploadMb} MB`;
|
| 68 |
-
hint.textContent = `Max ${label} · GeoTIFF engine: ${ddaConfig.geotiffEnabled ? 'yes' : 'limited'}`;
|
| 69 |
}
|
| 70 |
-
|
| 71 |
-
if (
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
await loadLibraryImages();
|
| 74 |
} catch (err) {
|
| 75 |
-
showDdaError(err.message || 'Failed to load
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
async function loadLibraryImages() {
|
| 80 |
const grid = document.getElementById('lib-grid');
|
|
|
|
| 81 |
if (!grid) return;
|
| 82 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 83 |
const params = new URLSearchParams();
|
| 84 |
-
if (
|
| 85 |
-
else if (selectedZoneId) params.set('zone_id', String(selectedZoneId));
|
| 86 |
if (q) params.set('q', q);
|
|
|
|
|
|
|
|
|
|
| 87 |
try {
|
| 88 |
-
const items = await ddaApi('GET', '/api/dda/images?' + params.toString());
|
| 89 |
if (!items.length) {
|
| 90 |
-
grid.innerHTML =
|
| 91 |
return;
|
| 92 |
}
|
| 93 |
grid.innerHTML = items.map((img) => `
|
| 94 |
-
<div class="dda-card-img" draggable="true" data-image-
|
| 95 |
${img.thumbUrl ? `<img src="${img.thumbUrl}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 96 |
<div class="meta">
|
| 97 |
-
<strong>${img.year
|
| 98 |
-
${img.
|
| 99 |
-
${img.
|
| 100 |
</div>
|
| 101 |
</div>`).join('');
|
| 102 |
grid.querySelectorAll('.dda-card-img').forEach((card) => {
|
| 103 |
card.addEventListener('dragstart', (e) => {
|
| 104 |
-
e.dataTransfer.setData('
|
|
|
|
| 105 |
});
|
| 106 |
});
|
| 107 |
} catch (err) {
|
|
@@ -114,4 +133,17 @@ document.getElementById('lib-filter')?.addEventListener('input', () => {
|
|
| 114 |
window._libFilterTimer = setTimeout(loadLibraryImages, 300);
|
| 115 |
});
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
initDda();
|
|
|
|
| 30 |
setTimeout(() => el.classList.add('hidden'), 4000);
|
| 31 |
}
|
| 32 |
|
| 33 |
+
function formatBytes(n) {
|
| 34 |
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 35 |
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
| 36 |
+
return (n / 1024).toFixed(0) + ' KB';
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
let ddaConfig = null;
|
| 40 |
+
let localYears = [];
|
| 41 |
+
let selectedYear = null;
|
|
|
|
| 42 |
|
| 43 |
window.ddaState = {
|
|
|
|
| 44 |
get config() { return ddaConfig; },
|
| 45 |
+
get years() { return localYears; },
|
| 46 |
+
get selectedYear() { return selectedYear; },
|
| 47 |
+
setYear(year) { selectedYear = year; },
|
|
|
|
|
|
|
|
|
|
| 48 |
refreshImages: () => loadLibraryImages(),
|
| 49 |
+
rescan: () => rescanLibrary(),
|
| 50 |
};
|
| 51 |
|
| 52 |
document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
|
|
| 59 |
});
|
| 60 |
});
|
| 61 |
|
| 62 |
+
async function rescanLibrary() {
|
| 63 |
+
const data = await ddaApi('POST', '/api/dda/local/rescan');
|
| 64 |
+
localYears = data.years || [];
|
| 65 |
+
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
| 66 |
+
await loadLibraryImages();
|
| 67 |
+
return data;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
async function initDda() {
|
| 71 |
hideDdaError();
|
| 72 |
try {
|
| 73 |
ddaConfig = await ddaApi('GET', '/api/dda/config');
|
| 74 |
+
const localCfg = await ddaApi('GET', '/api/dda/local/config');
|
| 75 |
+
|
| 76 |
const hint = document.getElementById('lib-config-hint');
|
| 77 |
if (hint) {
|
| 78 |
+
hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
+
const pathEl = document.getElementById('lib-path-display');
|
| 81 |
+
if (pathEl) pathEl.textContent = localCfg.rootPath || ddaConfig.localLibraryPath || '';
|
| 82 |
+
const folderPath = document.getElementById('lib-folder-path');
|
| 83 |
+
if (folderPath) folderPath.textContent = 'Folder: library_sources/';
|
| 84 |
+
|
| 85 |
+
const yearsData = await ddaApi('GET', '/api/dda/local/years');
|
| 86 |
+
localYears = yearsData.years || [];
|
| 87 |
+
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
| 88 |
await loadLibraryImages();
|
| 89 |
} catch (err) {
|
| 90 |
+
showDdaError(err.message || 'Failed to load library');
|
| 91 |
}
|
| 92 |
}
|
| 93 |
|
| 94 |
async function loadLibraryImages() {
|
| 95 |
const grid = document.getElementById('lib-grid');
|
| 96 |
+
const title = document.getElementById('lib-grid-title');
|
| 97 |
if (!grid) return;
|
| 98 |
const q = document.getElementById('lib-filter')?.value?.trim() || '';
|
| 99 |
const params = new URLSearchParams();
|
| 100 |
+
if (selectedYear) params.set('year', String(selectedYear));
|
|
|
|
| 101 |
if (q) params.set('q', q);
|
| 102 |
+
if (title) {
|
| 103 |
+
title.textContent = selectedYear ? `Images — ${selectedYear}` : 'Images — all years';
|
| 104 |
+
}
|
| 105 |
try {
|
| 106 |
+
const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
|
| 107 |
if (!items.length) {
|
| 108 |
+
grid.innerHTML = `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
|
| 109 |
return;
|
| 110 |
}
|
| 111 |
grid.innerHTML = items.map((img) => `
|
| 112 |
+
<div class="dda-card-img" draggable="true" data-image-path="${img.path}" title="${img.filename}">
|
| 113 |
${img.thumbUrl ? `<img src="${img.thumbUrl}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 114 |
<div class="meta">
|
| 115 |
+
<strong>${img.year}</strong><br/>
|
| 116 |
+
${img.filename}<br/>
|
| 117 |
+
<span class="dim">${formatBytes(img.fileSizeBytes)}</span>
|
| 118 |
</div>
|
| 119 |
</div>`).join('');
|
| 120 |
grid.querySelectorAll('.dda-card-img').forEach((card) => {
|
| 121 |
card.addEventListener('dragstart', (e) => {
|
| 122 |
+
e.dataTransfer.setData('application/x-dda-image-path', card.dataset.imagePath);
|
| 123 |
+
e.dataTransfer.setData('text/plain', card.dataset.imagePath);
|
| 124 |
});
|
| 125 |
});
|
| 126 |
} catch (err) {
|
|
|
|
| 133 |
window._libFilterTimer = setTimeout(loadLibraryImages, 300);
|
| 134 |
});
|
| 135 |
|
| 136 |
+
document.getElementById('btn-refresh-lib')?.addEventListener('click', async () => {
|
| 137 |
+
const btn = document.getElementById('btn-refresh-lib');
|
| 138 |
+
btn.disabled = true;
|
| 139 |
+
try {
|
| 140 |
+
const data = await rescanLibrary();
|
| 141 |
+
showDdaSuccess(`Library refreshed — ${data.totalImages || 0} image(s) found.`);
|
| 142 |
+
} catch (err) {
|
| 143 |
+
showDdaError(err.message);
|
| 144 |
+
} finally {
|
| 145 |
+
btn.disabled = false;
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
initDda();
|
static/js/dda/library.js
CHANGED
|
@@ -1,169 +1,33 @@
|
|
| 1 |
-
function
|
| 2 |
const tree = document.getElementById('lib-tree');
|
| 3 |
-
if (!tree
|
| 4 |
const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
window.ddaState.refreshImages();
|
| 29 |
-
});
|
| 30 |
-
});
|
| 31 |
|
| 32 |
-
tree.
|
|
|
|
|
|
|
| 33 |
btn.addEventListener('click', () => {
|
| 34 |
-
tree.querySelectorAll('.dda-tree-
|
| 35 |
btn.classList.add('active');
|
| 36 |
-
btn.
|
| 37 |
-
window.ddaState.
|
| 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?.
|
| 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 |
-
function formatBytes(n) {
|
| 83 |
-
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 84 |
-
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
| 85 |
-
return (n / 1024).toFixed(0) + ' KB';
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function uploadWithProgress(url, formData, onProgress) {
|
| 89 |
-
return new Promise((resolve, reject) => {
|
| 90 |
-
const xhr = new XMLHttpRequest();
|
| 91 |
-
xhr.open('POST', url);
|
| 92 |
-
xhr.withCredentials = true;
|
| 93 |
-
xhr.upload.addEventListener('progress', (e) => {
|
| 94 |
-
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
|
| 95 |
-
});
|
| 96 |
-
xhr.addEventListener('load', () => {
|
| 97 |
-
let data = null;
|
| 98 |
-
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (_) {}
|
| 99 |
-
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
|
| 100 |
-
else reject(new Error(data?.detail || xhr.statusText || 'Upload failed'));
|
| 101 |
-
});
|
| 102 |
-
xhr.addEventListener('error', () => reject(new Error('Network error during upload')));
|
| 103 |
-
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled')));
|
| 104 |
-
xhr.send(formData);
|
| 105 |
-
});
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
document.getElementById('form-upload')?.addEventListener('submit', async (e) => {
|
| 109 |
-
e.preventDefault();
|
| 110 |
-
hideDdaError?.();
|
| 111 |
-
const fileInput = document.getElementById('up-file');
|
| 112 |
-
const file = fileInput?.files?.[0];
|
| 113 |
-
if (!file) {
|
| 114 |
-
showDdaError?.('Select a file to upload.');
|
| 115 |
-
return;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
const form = new FormData();
|
| 119 |
-
form.append('file', file);
|
| 120 |
-
form.append('zone_id', document.getElementById('up-zone').value);
|
| 121 |
-
form.append('village_id', document.getElementById('up-village').value);
|
| 122 |
-
form.append('area_name', document.getElementById('up-area').value || '');
|
| 123 |
-
form.append('year', document.getElementById('up-year').value);
|
| 124 |
-
form.append('capture_date', document.getElementById('up-date').value);
|
| 125 |
-
form.append('source', document.getElementById('up-source').value);
|
| 126 |
-
form.append('manual_bounds_json', document.getElementById('up-manual-bounds').value || '');
|
| 127 |
-
|
| 128 |
-
const btn = document.getElementById('btn-upload');
|
| 129 |
-
const progWrap = document.getElementById('upload-progress');
|
| 130 |
-
const progFill = document.getElementById('upload-progress-fill');
|
| 131 |
-
const progLabel = document.getElementById('upload-progress-label');
|
| 132 |
-
|
| 133 |
-
btn.disabled = true;
|
| 134 |
-
btn.textContent = 'Uploading…';
|
| 135 |
-
progWrap?.classList.remove('hidden');
|
| 136 |
-
if (progFill) progFill.style.width = '0%';
|
| 137 |
-
if (progLabel) progLabel.textContent = `Uploading ${file.name} (${formatBytes(file.size)})… 0%`;
|
| 138 |
-
|
| 139 |
-
try {
|
| 140 |
-
const data = await uploadWithProgress('/api/dda/images/upload', form, (loaded, total) => {
|
| 141 |
-
const pct = total ? Math.round((loaded / total) * 100) : 0;
|
| 142 |
-
if (progFill) progFill.style.width = pct + '%';
|
| 143 |
-
if (progLabel) progLabel.textContent = `Uploading… ${pct}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
|
| 144 |
-
});
|
| 145 |
-
if (progFill) progFill.style.width = '100%';
|
| 146 |
-
showDdaSuccess?.(data?.status === 'success' ? 'Image uploaded to library.' : 'Upload complete.');
|
| 147 |
-
document.getElementById('form-upload').reset();
|
| 148 |
-
document.getElementById('up-year').value = '2025';
|
| 149 |
-
fileInput.value = '';
|
| 150 |
-
const dateInput = document.getElementById('up-date');
|
| 151 |
-
if (dateInput) dateInput.value = new Date().toISOString().slice(0, 10);
|
| 152 |
-
window.ddaState.hierarchy = await ddaApi('GET', '/api/dda/hierarchy');
|
| 153 |
-
renderHierarchy(window.ddaState.hierarchy);
|
| 154 |
-
populateUploadSelects(window.ddaState.hierarchy);
|
| 155 |
-
await window.ddaState.refreshImages();
|
| 156 |
-
} catch (err) {
|
| 157 |
-
showDdaError?.(err.message || 'Upload failed');
|
| 158 |
-
} finally {
|
| 159 |
-
btn.disabled = false;
|
| 160 |
-
btn.textContent = 'Upload to Library';
|
| 161 |
-
setTimeout(() => progWrap?.classList.add('hidden'), 1500);
|
| 162 |
-
}
|
| 163 |
-
});
|
| 164 |
-
|
| 165 |
-
// Default capture date = today
|
| 166 |
-
const dateInput = document.getElementById('up-date');
|
| 167 |
-
if (dateInput && !dateInput.value) {
|
| 168 |
-
dateInput.value = new Date().toISOString().slice(0, 10);
|
| 169 |
-
}
|
|
|
|
| 1 |
+
function renderYearTree(years) {
|
| 2 |
const tree = document.getElementById('lib-tree');
|
| 3 |
+
if (!tree) return;
|
| 4 |
const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
|
| 5 |
|
| 6 |
+
const allBtn = `
|
| 7 |
+
<button type="button" class="dda-tree-year ${window.ddaState.selectedYear === null ? 'active' : ''}" data-year="">
|
| 8 |
+
All years
|
| 9 |
+
</button>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
const yearBtns = (years || [])
|
| 12 |
+
.filter((y) => !filter || String(y.year).includes(filter))
|
| 13 |
+
.map((y) => `
|
| 14 |
+
<button type="button" class="dda-tree-year ${window.ddaState.selectedYear === y.year ? 'active' : ''}" data-year="${y.year}">
|
| 15 |
+
${y.year} <span class="dim">(${y.imageCount})</span>
|
| 16 |
+
</button>`).join('');
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
tree.innerHTML = allBtn + yearBtns;
|
| 19 |
+
|
| 20 |
+
tree.querySelectorAll('.dda-tree-year').forEach((btn) => {
|
| 21 |
btn.addEventListener('click', () => {
|
| 22 |
+
tree.querySelectorAll('.dda-tree-year').forEach((b) => b.classList.remove('active'));
|
| 23 |
btn.classList.add('active');
|
| 24 |
+
const raw = btn.dataset.year;
|
| 25 |
+
window.ddaState.setYear(raw ? parseInt(raw, 10) : null);
|
|
|
|
|
|
|
|
|
|
| 26 |
window.ddaState.refreshImages();
|
| 27 |
});
|
| 28 |
});
|
| 29 |
}
|
| 30 |
|
| 31 |
document.getElementById('lib-tree-search')?.addEventListener('input', () => {
|
| 32 |
+
if (window.ddaState?.years) renderYearTree(window.ddaState.years);
|
| 33 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -25,70 +25,35 @@
|
|
| 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
|
| 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">
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 40 |
<span class="dim" id="lib-config-hint"></span>
|
| 41 |
</div>
|
| 42 |
-
<
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 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 (.tif up to 2 GB)</label>
|
| 72 |
-
<input type="file" id="up-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
|
| 73 |
-
</div>
|
| 74 |
-
</div>
|
| 75 |
-
<div id="upload-progress" class="dda-upload-progress hidden">
|
| 76 |
-
<div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
|
| 77 |
-
<span id="upload-progress-label" class="dim">Uploading… 0%</span>
|
| 78 |
-
</div>
|
| 79 |
-
<div class="form-group">
|
| 80 |
-
<label for="up-manual-bounds">Manual bounds (WGS84)</label>
|
| 81 |
-
<input type="text" id="up-manual-bounds" placeholder="west,south,east,north — required if GeoTIFF has no georef" />
|
| 82 |
-
</div>
|
| 83 |
-
<button type="submit" class="btn btn-primary" id="btn-upload">Upload to Library</button>
|
| 84 |
-
</form>
|
| 85 |
</div>
|
| 86 |
<div class="card">
|
| 87 |
<div class="card-header">
|
| 88 |
-
<h3
|
| 89 |
-
<input type="search" id="lib-filter" class="dda-search" placeholder="Search…" />
|
| 90 |
</div>
|
| 91 |
-
<div id="lib-grid" class="dda-grid"><p class="dim">Select a
|
| 92 |
</div>
|
| 93 |
</main>
|
| 94 |
</div>
|
|
@@ -123,7 +88,7 @@
|
|
| 123 |
</section>
|
| 124 |
</div>
|
| 125 |
|
| 126 |
-
<script src="/static/js/dda/app.js?v=
|
| 127 |
-
<script src="/static/js/dda/library.js?v=
|
| 128 |
</body>
|
| 129 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=3" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 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 — reads from library_sources/ year folders -->
|
| 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">
|
| 33 |
+
<h3>Years</h3>
|
| 34 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-refresh-lib" title="Rescan folder">Refresh</button>
|
| 35 |
+
</div>
|
| 36 |
+
<div id="lib-folder-path" class="dda-folder-path dim"></div>
|
| 37 |
+
<input type="search" id="lib-tree-search" class="dda-search" placeholder="Filter years…" />
|
| 38 |
<div id="lib-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
| 39 |
</aside>
|
| 40 |
<main class="dda-main">
|
| 41 |
+
<div class="card dda-instructions">
|
| 42 |
<div class="card-header">
|
| 43 |
+
<h3>Local folder library</h3>
|
| 44 |
<span class="dim" id="lib-config-hint"></span>
|
| 45 |
</div>
|
| 46 |
+
<p class="sub" id="lib-instructions">
|
| 47 |
+
Copy <code>.tif</code> images into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh</strong>.
|
| 48 |
+
</p>
|
| 49 |
+
<pre id="lib-path-display" class="dda-path-code"></pre>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
<div class="card">
|
| 52 |
<div class="card-header">
|
| 53 |
+
<h3 id="lib-grid-title">Images</h3>
|
| 54 |
+
<input type="search" id="lib-filter" class="dda-search" placeholder="Search filenames…" />
|
| 55 |
</div>
|
| 56 |
+
<div id="lib-grid" class="dda-grid"><p class="dim">Select a year or add images to library_sources/.</p></div>
|
| 57 |
</div>
|
| 58 |
</main>
|
| 59 |
</div>
|
|
|
|
| 88 |
</section>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
+
<script src="/static/js/dda/app.js?v=3"></script>
|
| 92 |
+
<script src="/static/js/dda/library.js?v=3"></script>
|
| 93 |
</body>
|
| 94 |
</html>
|