Spaces:
Sleeping
Sleeping
Commit ·
333dd2f
1
Parent(s): 4595db8
Fix local library scan: DDA mode in run.py, path encoding, gitignore large tifs
Browse files- .gitignore +6 -0
- Dockerfile +1 -1
- app/dda/config.py +29 -11
- app/dda/library_routes.py +2 -2
- app/dda/local_library.py +85 -48
- app/dda/local_routes.py +16 -6
- library_sources/README.md +19 -4
- run.py +3 -0
- static/js/dda/app.js +10 -6
- templates/index_dda.html +2 -2
.gitignore
CHANGED
|
@@ -4,6 +4,12 @@ __pycache__/
|
|
| 4 |
data/satellite_app.db
|
| 5 |
data/overlays/*
|
| 6 |
!data/overlays/.gitkeep
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
.env
|
| 8 |
*.egg-info/
|
| 9 |
dist/
|
|
|
|
| 4 |
data/satellite_app.db
|
| 5 |
data/overlays/*
|
| 6 |
!data/overlays/.gitkeep
|
| 7 |
+
# Local library images (too large for git — keep on disk only)
|
| 8 |
+
library_sources/**/*.tif
|
| 9 |
+
library_sources/**/*.tiff
|
| 10 |
+
library_sources/**/*.TIF
|
| 11 |
+
library_sources/**/*.TIFF
|
| 12 |
+
!library_sources/**/.gitkeep
|
| 13 |
.env
|
| 14 |
*.egg-info/
|
| 15 |
dist/
|
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=28
|
| 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/config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import os
|
| 2 |
from pathlib import Path
|
|
|
|
| 3 |
|
| 4 |
from ..database import DATA_DIR
|
| 5 |
|
|
@@ -9,10 +10,21 @@ IS_DDA_MODE = APP_MODE == "dda"
|
|
| 9 |
# Project root: change_detection_webapp/
|
| 10 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
LIBRARY_DIR = DATA_DIR / "library"
|
| 18 |
THUMBS_DIR = LIBRARY_DIR / "thumbs"
|
|
@@ -29,7 +41,12 @@ ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
|
| 29 |
|
| 30 |
|
| 31 |
def ensure_library_dirs() -> None:
|
| 32 |
-
for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
try:
|
| 34 |
d.mkdir(parents=True, exist_ok=True)
|
| 35 |
except OSError:
|
|
@@ -37,14 +54,15 @@ def ensure_library_dirs() -> None:
|
|
| 37 |
|
| 38 |
|
| 39 |
def ensure_local_year_folders() -> None:
|
| 40 |
-
"""Create default year folders under
|
| 41 |
from datetime import datetime
|
| 42 |
current = datetime.now().year
|
| 43 |
-
for
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def max_upload_bytes_for_extension(ext: str) -> int:
|
|
|
|
| 1 |
import os
|
| 2 |
from pathlib import Path
|
| 3 |
+
from typing import List
|
| 4 |
|
| 5 |
from ..database import DATA_DIR
|
| 6 |
|
|
|
|
| 10 |
# Project root: change_detection_webapp/
|
| 11 |
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def get_library_roots() -> List[Path]:
|
| 15 |
+
"""All folders scanned for year-based images (project + writable data copy)."""
|
| 16 |
+
roots: List[Path] = []
|
| 17 |
+
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 18 |
+
roots.append(Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve())
|
| 19 |
+
for candidate in (PROJECT_ROOT / "library_sources", DATA_DIR / "library_sources"):
|
| 20 |
+
resolved = candidate.resolve()
|
| 21 |
+
if resolved not in roots:
|
| 22 |
+
roots.append(resolved)
|
| 23 |
+
return roots
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Primary root (shown in UI) — first entry from get_library_roots()
|
| 27 |
+
LOCAL_LIBRARY_ROOT = get_library_roots()[0]
|
| 28 |
|
| 29 |
LIBRARY_DIR = DATA_DIR / "library"
|
| 30 |
THUMBS_DIR = LIBRARY_DIR / "thumbs"
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def ensure_library_dirs() -> None:
|
| 44 |
+
for root in get_library_roots():
|
| 45 |
+
try:
|
| 46 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
except OSError:
|
| 48 |
+
pass
|
| 49 |
+
for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR, LOCAL_THUMB_CACHE):
|
| 50 |
try:
|
| 51 |
d.mkdir(parents=True, exist_ok=True)
|
| 52 |
except OSError:
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def ensure_local_year_folders() -> None:
|
| 57 |
+
"""Create default year folders under each library root if missing."""
|
| 58 |
from datetime import datetime
|
| 59 |
current = datetime.now().year
|
| 60 |
+
for root in get_library_roots():
|
| 61 |
+
for year in (current - 1, current, current + 1):
|
| 62 |
+
try:
|
| 63 |
+
(root / str(year)).mkdir(parents=True, exist_ok=True)
|
| 64 |
+
except OSError:
|
| 65 |
+
pass
|
| 66 |
|
| 67 |
|
| 68 |
def max_upload_bytes_for_extension(ext: str) -> int:
|
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",
|
|
@@ -77,7 +77,7 @@ def dda_config():
|
|
| 77 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 78 |
"hierarchyMode": "admin",
|
| 79 |
"librarySource": "local_folder",
|
| 80 |
-
"
|
| 81 |
}
|
| 82 |
|
| 83 |
|
|
|
|
| 65 |
@router.get("/config")
|
| 66 |
def dda_config():
|
| 67 |
_require_dda()
|
| 68 |
+
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
|
|
| 77 |
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 78 |
"hierarchyMode": "admin",
|
| 79 |
"librarySource": "local_folder",
|
| 80 |
+
"localLibraryPaths": [str(r) for r in get_library_roots()],
|
| 81 |
}
|
| 82 |
|
| 83 |
|
app/dda/local_library.py
CHANGED
|
@@ -6,12 +6,10 @@ Folder layout (under library_sources/):
|
|
| 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
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
@@ -20,10 +18,11 @@ 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,
|
| 27 |
from .geotiff_io import inspect_image, raster_to_preview_png
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
|
@@ -32,6 +31,7 @@ logger = logging.getLogger(__name__)
|
|
| 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
|
|
@@ -41,73 +41,80 @@ 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
|
| 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
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
return
|
| 78 |
|
| 79 |
|
| 80 |
def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
|
| 81 |
-
"""Scan
|
| 82 |
ensure_root()
|
| 83 |
results: List[LocalImageEntry] = []
|
| 84 |
-
|
| 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
|
| 97 |
-
y
|
| 98 |
-
|
| 99 |
-
if
|
| 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,
|
|
@@ -117,13 +124,15 @@ def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List
|
|
| 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={
|
| 126 |
"source": "local_folder",
|
|
|
|
| 127 |
}
|
| 128 |
if include_meta:
|
| 129 |
try:
|
|
@@ -148,7 +157,7 @@ def thumb_cache_path(relative_path: str) -> Path:
|
|
| 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)
|
|
@@ -156,5 +165,33 @@ def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
|
| 156 |
|
| 157 |
|
| 158 |
def ensure_root() -> None:
|
| 159 |
-
|
|
|
|
| 160 |
LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
library_sources/
|
| 7 |
2024/
|
| 8 |
image_a.tif
|
|
|
|
|
|
|
| 9 |
2025/
|
| 10 |
image_c.tif
|
| 11 |
|
| 12 |
+
Copy files into library_sources/YEAR/ in the project folder (or data/library_sources/ on HF).
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
|
|
| 18 |
from dataclasses import dataclass
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import List, Optional
|
| 21 |
+
from urllib.parse import quote
|
| 22 |
|
| 23 |
from fastapi import HTTPException
|
| 24 |
|
| 25 |
+
from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
|
| 26 |
from .geotiff_io import inspect_image, raster_to_preview_png
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
|
|
|
| 31 |
@dataclass
|
| 32 |
class LocalImageEntry:
|
| 33 |
path: str # relative posix path, e.g. 2025/aerial.tif
|
| 34 |
+
root: Path
|
| 35 |
year: int
|
| 36 |
filename: str
|
| 37 |
file_size_bytes: int
|
|
|
|
| 41 |
return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
|
| 42 |
|
| 43 |
|
| 44 |
+
def _iter_image_files(root: Path, year: Optional[int] = None):
|
| 45 |
+
if not root.exists():
|
| 46 |
+
return
|
| 47 |
+
if year is not None:
|
| 48 |
+
year_dirs = [root / str(year)] if (root / str(year)).is_dir() else []
|
| 49 |
+
else:
|
| 50 |
+
year_dirs = [d for d in sorted(root.iterdir()) if d.is_dir() and _is_year_dir(d.name)]
|
| 51 |
+
for ydir in year_dirs:
|
| 52 |
+
y = int(ydir.name)
|
| 53 |
+
for path in sorted(ydir.rglob("*")):
|
| 54 |
+
if not path.is_file():
|
| 55 |
+
continue
|
| 56 |
+
ext = path.suffix.lower()
|
| 57 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 58 |
+
logger.debug("Skipped (extension %s): %s", ext, path)
|
| 59 |
+
continue
|
| 60 |
+
yield root, y, path
|
| 61 |
+
|
| 62 |
+
|
| 63 |
def safe_resolve(relative_path: str) -> Path:
|
| 64 |
+
"""Resolve a library-relative path across all configured roots."""
|
| 65 |
rel = relative_path.replace("\\", "/").lstrip("/")
|
| 66 |
if not rel or ".." in rel.split("/"):
|
| 67 |
raise HTTPException(status_code=400, detail="Invalid image path")
|
| 68 |
+
for root in get_library_roots():
|
| 69 |
+
full = (root / rel).resolve()
|
| 70 |
+
try:
|
| 71 |
+
full.relative_to(root.resolve())
|
| 72 |
+
except ValueError:
|
| 73 |
+
continue
|
| 74 |
+
if full.is_file() and full.suffix.lower() in ALLOWED_EXTENSIONS:
|
| 75 |
+
return full
|
| 76 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
def scan_years() -> List[dict]:
|
| 80 |
+
"""List year folders and image counts (merged across all roots)."""
|
| 81 |
ensure_root()
|
| 82 |
+
counts: dict[int, int] = {}
|
| 83 |
+
for root in get_library_roots():
|
| 84 |
+
if not root.exists():
|
|
|
|
|
|
|
| 85 |
continue
|
| 86 |
+
for entry in sorted(root.iterdir()):
|
| 87 |
+
if not entry.is_dir() or not _is_year_dir(entry.name):
|
| 88 |
+
continue
|
| 89 |
+
y = int(entry.name)
|
| 90 |
+
n = sum(1 for _ in _iter_image_files(root, year=y))
|
| 91 |
+
counts[y] = counts.get(y, 0) + n
|
| 92 |
+
return [{"year": y, "imageCount": counts[y]} for y in sorted(counts)]
|
| 93 |
|
| 94 |
|
| 95 |
def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
|
| 96 |
+
"""Scan all library roots for images."""
|
| 97 |
ensure_root()
|
| 98 |
results: List[LocalImageEntry] = []
|
| 99 |
+
seen_paths: set[str] = set()
|
|
|
|
|
|
|
|
|
|
| 100 |
q = (query or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
for root in get_library_roots():
|
| 103 |
+
for r, y, path in _iter_image_files(root, year=year):
|
| 104 |
+
rel = path.relative_to(r).as_posix()
|
| 105 |
+
if rel in seen_paths:
|
| 106 |
continue
|
|
|
|
| 107 |
if q and q not in rel.lower():
|
| 108 |
continue
|
| 109 |
try:
|
| 110 |
size = path.stat().st_size
|
| 111 |
except OSError:
|
| 112 |
continue
|
| 113 |
+
seen_paths.add(rel)
|
| 114 |
results.append(
|
| 115 |
LocalImageEntry(
|
| 116 |
path=rel,
|
| 117 |
+
root=r,
|
| 118 |
year=y,
|
| 119 |
filename=path.name,
|
| 120 |
file_size_bytes=size,
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def entry_to_dict(entry: LocalImageEntry, include_meta: bool = False) -> dict:
|
| 127 |
+
encoded_path = quote(entry.path, safe="/")
|
| 128 |
out = {
|
| 129 |
"path": entry.path,
|
| 130 |
"year": entry.year,
|
| 131 |
"filename": entry.filename,
|
| 132 |
"fileSizeBytes": entry.file_size_bytes,
|
| 133 |
+
"thumbUrl": f"/api/dda/local/thumb?path={encoded_path}",
|
| 134 |
"source": "local_folder",
|
| 135 |
+
"rootPath": str(entry.root),
|
| 136 |
}
|
| 137 |
if include_meta:
|
| 138 |
try:
|
|
|
|
| 157 |
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 158 |
full = safe_resolve(relative_path)
|
| 159 |
cache = thumb_cache_path(relative_path)
|
| 160 |
+
if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
|
| 161 |
return cache
|
| 162 |
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 163 |
raster_to_preview_png(full, cache, max_side=max_side)
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
def ensure_root() -> None:
|
| 168 |
+
for root in get_library_roots():
|
| 169 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 170 |
LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def library_debug_info() -> dict:
|
| 174 |
+
"""Diagnostics for troubleshooting missing images."""
|
| 175 |
+
roots_info = []
|
| 176 |
+
for root in get_library_roots():
|
| 177 |
+
info = {
|
| 178 |
+
"path": str(root),
|
| 179 |
+
"exists": root.exists(),
|
| 180 |
+
"years": [],
|
| 181 |
+
"otherFiles": [],
|
| 182 |
+
}
|
| 183 |
+
if root.exists():
|
| 184 |
+
for entry in sorted(root.iterdir()):
|
| 185 |
+
if entry.is_dir() and _is_year_dir(entry.name):
|
| 186 |
+
files = []
|
| 187 |
+
for _, _, p in _iter_image_files(root, year=int(entry.name)):
|
| 188 |
+
files.append({"name": p.name, "size": p.stat().st_size})
|
| 189 |
+
info["years"].append({"year": entry.name, "files": files})
|
| 190 |
+
elif entry.is_file() and entry.name not in ("README.md", ".gitkeep"):
|
| 191 |
+
info["otherFiles"].append(entry.name)
|
| 192 |
+
roots_info.append(info)
|
| 193 |
+
return {
|
| 194 |
+
"roots": roots_info,
|
| 195 |
+
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 196 |
+
"totalImages": len(scan_images()),
|
| 197 |
+
}
|
app/dda/local_routes.py
CHANGED
|
@@ -4,10 +4,11 @@ from typing import Optional
|
|
| 4 |
from fastapi import APIRouter, HTTPException, Query
|
| 5 |
from fastapi.responses import FileResponse
|
| 6 |
|
| 7 |
-
from .config import IS_DDA_MODE,
|
| 8 |
from .local_library import (
|
| 9 |
entry_to_dict,
|
| 10 |
get_or_build_thumb,
|
|
|
|
| 11 |
safe_resolve,
|
| 12 |
scan_images,
|
| 13 |
scan_years,
|
|
@@ -24,21 +25,30 @@ def _require_dda():
|
|
| 24 |
@router.get("/local/config")
|
| 25 |
def local_library_config():
|
| 26 |
_require_dda()
|
|
|
|
| 27 |
return {
|
| 28 |
"source": "local_folder",
|
| 29 |
-
"rootPath":
|
|
|
|
| 30 |
"instructions": (
|
| 31 |
-
"Copy .tif / .tiff images into
|
| 32 |
-
"(e.g. library_sources/2025/
|
|
|
|
| 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(), "
|
| 42 |
|
| 43 |
|
| 44 |
@router.get("/local/images")
|
|
@@ -82,4 +92,4 @@ 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, "
|
|
|
|
| 4 |
from fastapi import APIRouter, HTTPException, Query
|
| 5 |
from fastapi.responses import FileResponse
|
| 6 |
|
| 7 |
+
from .config import IS_DDA_MODE, geotiff_io_available, get_library_roots
|
| 8 |
from .local_library import (
|
| 9 |
entry_to_dict,
|
| 10 |
get_or_build_thumb,
|
| 11 |
+
library_debug_info,
|
| 12 |
safe_resolve,
|
| 13 |
scan_images,
|
| 14 |
scan_years,
|
|
|
|
| 25 |
@router.get("/local/config")
|
| 26 |
def local_library_config():
|
| 27 |
_require_dda()
|
| 28 |
+
roots = [str(r) for r in get_library_roots()]
|
| 29 |
return {
|
| 30 |
"source": "local_folder",
|
| 31 |
+
"rootPath": roots[0] if roots else "",
|
| 32 |
+
"rootPaths": roots,
|
| 33 |
"instructions": (
|
| 34 |
+
"Copy .tif / .tiff images into library_sources/YEAR/ inside the project folder "
|
| 35 |
+
"(e.g. change_detection_webapp/library_sources/2025/), then click Refresh. "
|
| 36 |
+
"Run locally with: python run.py (DDA mode is enabled automatically)."
|
| 37 |
),
|
| 38 |
"geotiffEnabled": geotiff_io_available(),
|
| 39 |
}
|
| 40 |
|
| 41 |
|
| 42 |
+
@router.get("/local/debug")
|
| 43 |
+
def local_debug():
|
| 44 |
+
_require_dda()
|
| 45 |
+
return library_debug_info()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
@router.get("/local/years")
|
| 49 |
def local_years():
|
| 50 |
_require_dda()
|
| 51 |
+
return {"years": scan_years(), "rootPaths": [str(r) for r in get_library_roots()]}
|
| 52 |
|
| 53 |
|
| 54 |
@router.get("/local/images")
|
|
|
|
| 92 |
_require_dda()
|
| 93 |
years = scan_years()
|
| 94 |
total = sum(y["imageCount"] for y in years)
|
| 95 |
+
return {"ok": True, "years": years, "totalImages": total, "rootPaths": [str(r) for r in get_library_roots()]}
|
library_sources/README.md
CHANGED
|
@@ -22,10 +22,25 @@ library_sources/
|
|
| 22 |
|
| 23 |
## How to use
|
| 24 |
|
| 25 |
-
1. Copy your images into the correct **year** folder
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
## Large files
|
| 31 |
|
|
|
|
| 22 |
|
| 23 |
## How to use
|
| 24 |
|
| 25 |
+
1. Copy your images into the correct **year** folder:
|
| 26 |
+
```
|
| 27 |
+
change_detection_webapp/library_sources/2025/your_image.tif
|
| 28 |
+
change_detection_webapp/library_sources/2026/other_image.tif
|
| 29 |
+
```
|
| 30 |
+
2. Start the app locally: `python run.py` (DDA mode is enabled automatically).
|
| 31 |
+
3. Open **Image Library** → click **Refresh**.
|
| 32 |
+
4. Select **2025** or **2026** in the sidebar to filter by year.
|
| 33 |
+
|
| 34 |
+
> **Hugging Face dev Space:** large `.tif` files are **not** uploaded via git. Run locally, or copy files into the Space persistent `data/library_sources/` folder.
|
| 35 |
+
|
| 36 |
+
## Troubleshooting
|
| 37 |
+
|
| 38 |
+
| Problem | Fix |
|
| 39 |
+
|---------|-----|
|
| 40 |
+
| Images not showing | Use `python run.py` locally; click **Refresh** |
|
| 41 |
+
| Wrong page (simple upload UI) | Set `APP_MODE=dda` on HF, or run locally |
|
| 42 |
+
| Filename has spaces | Supported (e.g. `Grid no. 54_ORI-005.tif`) |
|
| 43 |
+
| Check scan | Open `/api/dda/local/debug` in browser |
|
| 44 |
|
| 45 |
## Large files
|
| 46 |
|
run.py
CHANGED
|
@@ -35,6 +35,9 @@ def main():
|
|
| 35 |
if here not in sys.path:
|
| 36 |
sys.path.insert(0, here)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
try:
|
| 39 |
import uvicorn
|
| 40 |
except ImportError:
|
|
|
|
| 35 |
if here not in sys.path:
|
| 36 |
sys.path.insert(0, here)
|
| 37 |
|
| 38 |
+
# Local runs use DDA dev UI + folder library unless already set
|
| 39 |
+
os.environ.setdefault("APP_MODE", "dda")
|
| 40 |
+
|
| 41 |
try:
|
| 42 |
import uvicorn
|
| 43 |
except ImportError:
|
static/js/dda/app.js
CHANGED
|
@@ -77,10 +77,11 @@ async function initDda() {
|
|
| 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 =
|
| 82 |
const folderPath = document.getElementById('lib-folder-path');
|
| 83 |
-
if (folderPath) folderPath.textContent =
|
| 84 |
|
| 85 |
const yearsData = await ddaApi('GET', '/api/dda/local/years');
|
| 86 |
localYears = yearsData.years || [];
|
|
@@ -108,15 +109,18 @@ async function loadLibraryImages() {
|
|
| 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 |
-
|
| 113 |
-
|
|
|
|
|
|
|
| 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>`
|
|
|
|
| 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);
|
|
|
|
| 77 |
if (hint) {
|
| 78 |
hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
|
| 79 |
}
|
| 80 |
+
const paths = (localCfg.rootPaths || [localCfg.rootPath || ddaConfig.localLibraryPaths?.[0]]).filter(Boolean);
|
| 81 |
const pathEl = document.getElementById('lib-path-display');
|
| 82 |
+
if (pathEl) pathEl.textContent = paths.join('\n');
|
| 83 |
const folderPath = document.getElementById('lib-folder-path');
|
| 84 |
+
if (folderPath) folderPath.textContent = paths.length ? `Scanning: ${paths[0]}` : '';
|
| 85 |
|
| 86 |
const yearsData = await ddaApi('GET', '/api/dda/local/years');
|
| 87 |
localYears = yearsData.years || [];
|
|
|
|
| 109 |
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>`;
|
| 110 |
return;
|
| 111 |
}
|
| 112 |
+
grid.innerHTML = items.map((img) => {
|
| 113 |
+
const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
|
| 114 |
+
return `
|
| 115 |
+
<div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '"')}" title="${img.filename}">
|
| 116 |
+
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 117 |
<div class="meta">
|
| 118 |
<strong>${img.year}</strong><br/>
|
| 119 |
${img.filename}<br/>
|
| 120 |
<span class="dim">${formatBytes(img.fileSizeBytes)}</span>
|
| 121 |
</div>
|
| 122 |
+
</div>`;
|
| 123 |
+
}).join('');
|
| 124 |
grid.querySelectorAll('.dda-card-img').forEach((card) => {
|
| 125 |
card.addEventListener('dragstart', (e) => {
|
| 126 |
e.dataTransfer.setData('application/x-dda-image-path', card.dataset.imagePath);
|
templates/index_dda.html
CHANGED
|
@@ -88,7 +88,7 @@
|
|
| 88 |
</section>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
-
<script src="/static/js/dda/app.js?v=
|
| 92 |
-
<script src="/static/js/dda/library.js?v=
|
| 93 |
</body>
|
| 94 |
</html>
|
|
|
|
| 88 |
</section>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
+
<script src="/static/js/dda/app.js?v=4"></script>
|
| 92 |
+
<script src="/static/js/dda/library.js?v=4"></script>
|
| 93 |
</body>
|
| 94 |
</html>
|