Spaces:
Sleeping
Sleeping
Commit ·
15e9574
1
Parent(s): d9820a1
Add compare tab tree sidebar, fix geo coordinates, and sync local folders to library.
Browse files- DEPLOYMENT.md +3 -2
- app/dda/bootstrap.py +3 -0
- app/dda/detect_service.py +24 -6
- app/dda/geo_regions.py +95 -15
- app/dda/geotiff_io.py +71 -1
- app/dda/job_runner.py +1 -0
- app/dda/local_routes.py +5 -0
- app/dda/tree/image_service.py +7 -0
- app/dda/tree/sync_service.py +203 -0
- static/js/dda/app.js +8 -2
- static/js/dda/compare.js +120 -43
- static/js/dda/tree.js +65 -23
- templates/index_dda.html +72 -60
DEPLOYMENT.md
CHANGED
|
@@ -163,8 +163,9 @@ library_sources/
|
|
| 163 |
```
|
| 164 |
|
| 165 |
- **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
|
| 166 |
-
- **UI:** Recursive tree sidebar
|
| 167 |
- **Storage:** Slug-based disk paths; display names in `node_path`
|
|
|
|
| 168 |
- **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
|
| 169 |
|
| 170 |
---
|
|
@@ -175,7 +176,7 @@ Run before promoting any DDA feature to production:
|
|
| 175 |
|
| 176 |
1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
|
| 177 |
2. **Tree library** — Create zone → area → year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
|
| 178 |
-
3. **Compare** —
|
| 179 |
4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
|
| 180 |
5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
|
| 181 |
6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
|
|
|
|
| 163 |
```
|
| 164 |
|
| 165 |
- **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
|
| 166 |
+
- **UI:** Recursive tree sidebar on Library and Change Detection tabs; **Manage** (admin) for create/rename/move/delete
|
| 167 |
- **Storage:** Slug-based disk paths; display names in `node_path`
|
| 168 |
+
- **Local folders:** Create folders under `library_sources/{zone}/{area}/…/` and drop files into `Images/`; click **Refresh** (or restart app) to sync disk → DB via `POST /api/dda/local/rescan`
|
| 169 |
- **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
|
| 170 |
|
| 171 |
---
|
|
|
|
| 176 |
|
| 177 |
1. **Health** — `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` ≥ 0
|
| 178 |
2. **Tree library** — Create zone → area → year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
|
| 179 |
+
3. **Compare** — Tree sidebar filters image grid; T1/T2 dropdowns list all images; Run Detection completes with plausible lat/lng on georeferenced TIFFs
|
| 180 |
4. **Viewer** — Slider / T1 / T2 / Overlay modes; click region to locate
|
| 181 |
5. **Review** — Confirm and False Positive; Export confirmed CSV; Submit confirmed
|
| 182 |
6. **Reports** — PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
|
app/dda/bootstrap.py
CHANGED
|
@@ -63,6 +63,9 @@ def init_dda_database():
|
|
| 63 |
from .tree.migration import run_tree_migration
|
| 64 |
mig = run_tree_migration(db)
|
| 65 |
logger.info("Tree migration: %s", mig)
|
|
|
|
|
|
|
|
|
|
| 66 |
from .job_runner import reconcile_stale_jobs
|
| 67 |
reconcile_stale_jobs(db)
|
| 68 |
finally:
|
|
|
|
| 63 |
from .tree.migration import run_tree_migration
|
| 64 |
mig = run_tree_migration(db)
|
| 65 |
logger.info("Tree migration: %s", mig)
|
| 66 |
+
from .tree.sync_service import sync_from_filesystem
|
| 67 |
+
sync_stats = sync_from_filesystem(db)
|
| 68 |
+
logger.info("Filesystem sync at startup: %s", sync_stats)
|
| 69 |
from .job_runner import reconcile_stale_jobs
|
| 70 |
reconcile_stale_jobs(db)
|
| 71 |
finally:
|
app/dda/detect_service.py
CHANGED
|
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
|
| 15 |
from ..auth import get_or_create_guest_user
|
| 16 |
from ..database import DATA_DIR
|
| 17 |
from ..models import DetectionRun
|
| 18 |
-
from .geo_regions import
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
@@ -28,7 +28,7 @@ def _serialize_regions(change_regions) -> list:
|
|
| 28 |
{
|
| 29 |
"id": int(r["id"]),
|
| 30 |
"area": int(r["area"]),
|
| 31 |
-
"center": {"x":
|
| 32 |
"bbox": {
|
| 33 |
"x": int(r["bbox"][0]),
|
| 34 |
"y": int(r["bbox"][1]),
|
|
@@ -79,6 +79,7 @@ def run_detection_and_save(
|
|
| 79 |
notify_email: Optional[str] = None,
|
| 80 |
max_size: Optional[int] = None,
|
| 81 |
geo_bounds_path: Optional[Path] = None,
|
|
|
|
| 82 |
user_id: Optional[int] = None,
|
| 83 |
job_id: Optional[int] = None,
|
| 84 |
) -> dict:
|
|
@@ -158,13 +159,30 @@ def run_detection_and_save(
|
|
| 158 |
logger.warning("Failed to save thumbnails: %s", exc)
|
| 159 |
|
| 160 |
regions_serializable = _serialize_regions(change_regions)
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
regions_serializable = enrich_regions_geo(
|
| 164 |
regions_serializable,
|
| 165 |
-
img_width=
|
| 166 |
-
img_height=
|
| 167 |
bounds=bounds,
|
|
|
|
| 168 |
)
|
| 169 |
total_px = int(stats["total_pixels"])
|
| 170 |
changed_px = int(stats["changed_pixels"])
|
|
|
|
| 15 |
from ..auth import get_or_create_guest_user
|
| 16 |
from ..database import DATA_DIR
|
| 17 |
from ..models import DetectionRun
|
| 18 |
+
from .geo_regions import enrich_regions_geo, resolve_geo_context
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
| 28 |
{
|
| 29 |
"id": int(r["id"]),
|
| 30 |
"area": int(r["area"]),
|
| 31 |
+
"center": {"x": round(float(r["center"][0])), "y": round(float(r["center"][1]))},
|
| 32 |
"bbox": {
|
| 33 |
"x": int(r["bbox"][0]),
|
| 34 |
"y": int(r["bbox"][1]),
|
|
|
|
| 79 |
notify_email: Optional[str] = None,
|
| 80 |
max_size: Optional[int] = None,
|
| 81 |
geo_bounds_path: Optional[Path] = None,
|
| 82 |
+
base_path: str = "",
|
| 83 |
user_id: Optional[int] = None,
|
| 84 |
job_id: Optional[int] = None,
|
| 85 |
) -> dict:
|
|
|
|
| 159 |
logger.warning("Failed to save thumbnails: %s", exc)
|
| 160 |
|
| 161 |
regions_serializable = _serialize_regions(change_regions)
|
| 162 |
+
det_w = int(stats.get("image_width") or 0)
|
| 163 |
+
det_h = int(stats.get("image_height") or 0)
|
| 164 |
+
if det_w <= 0 or det_h <= 0:
|
| 165 |
+
det_w, det_h = before_for_slider.size
|
| 166 |
+
|
| 167 |
+
geo_ctx = None
|
| 168 |
+
bounds = None
|
| 169 |
+
if geo_bounds_path:
|
| 170 |
+
rel_path = (base_path or "").replace("\\", "/").strip().lstrip("/")
|
| 171 |
+
if not rel_path:
|
| 172 |
+
try:
|
| 173 |
+
from .config import get_storage_root
|
| 174 |
+
rel_path = geo_bounds_path.resolve().relative_to(get_storage_root().resolve()).as_posix()
|
| 175 |
+
except Exception:
|
| 176 |
+
rel_path = geo_bounds_path.name
|
| 177 |
+
geo_ctx = resolve_geo_context(db, rel_path, geo_bounds_path)
|
| 178 |
+
bounds = geo_ctx.bounds
|
| 179 |
+
|
| 180 |
regions_serializable = enrich_regions_geo(
|
| 181 |
regions_serializable,
|
| 182 |
+
img_width=det_w,
|
| 183 |
+
img_height=det_h,
|
| 184 |
bounds=bounds,
|
| 185 |
+
geo=geo_ctx,
|
| 186 |
)
|
| 187 |
total_px = int(stats["total_pixels"])
|
| 188 |
changed_px = int(stats["changed_pixels"])
|
app/dda/geo_regions.py
CHANGED
|
@@ -3,17 +3,28 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
import json
|
| 5 |
import logging
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
from typing import Any, Dict, List, Optional, Tuple
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from .change_type_map import enrich_region_for_dda
|
| 10 |
-
from .geotiff_io import inspect_image
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
|
| 18 |
if bounds is None:
|
| 19 |
return None
|
|
@@ -49,18 +60,75 @@ def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
|
|
| 49 |
return None
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def pixel_to_lat_lng(
|
| 53 |
x: float,
|
| 54 |
y: float,
|
| 55 |
img_width: int,
|
| 56 |
img_height: int,
|
| 57 |
bounds: BoundsWGS84,
|
|
|
|
|
|
|
| 58 |
) -> Optional[Dict[str, float]]:
|
| 59 |
-
if img_width <= 0 or img_height <= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return None
|
|
|
|
| 61 |
west, south, east, north = bounds
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return {"lat": round(lat, 6), "lng": round(lng, 6)}
|
| 65 |
|
| 66 |
|
|
@@ -69,20 +137,25 @@ def bbox_area_sq_m(
|
|
| 69 |
img_width: int,
|
| 70 |
img_height: int,
|
| 71 |
bounds: BoundsWGS84,
|
|
|
|
|
|
|
| 72 |
) -> Optional[float]:
|
| 73 |
"""Approximate region area in square metres using geographic bounds."""
|
| 74 |
if img_width <= 0 or img_height <= 0 or not bounds:
|
| 75 |
return None
|
| 76 |
west, south, east, north = bounds
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
import math
|
| 81 |
mid_lat = (north + south) / 2.0
|
| 82 |
lat_scale = 111_320.0
|
| 83 |
lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
|
| 84 |
-
w_m = bbox.get("w", 0) * m_per_px_x * lng_scale
|
| 85 |
-
h_m = bbox.get("h", 0) * m_per_px_y * lat_scale
|
| 86 |
return round(w_m * h_m, 1)
|
| 87 |
|
| 88 |
|
|
@@ -92,22 +165,30 @@ def enrich_regions_geo(
|
|
| 92 |
img_width: int,
|
| 93 |
img_height: int,
|
| 94 |
bounds: Optional[BoundsWGS84],
|
|
|
|
| 95 |
) -> List[dict]:
|
| 96 |
"""Add latLng, areaSqM, and DDA change type to each region."""
|
|
|
|
| 97 |
out = []
|
| 98 |
for region in regions:
|
| 99 |
enriched = enrich_region_for_dda(region)
|
| 100 |
center = region.get("center") or {}
|
| 101 |
cx = center.get("x", 0)
|
| 102 |
cy = center.get("y", 0)
|
| 103 |
-
if
|
| 104 |
-
lat_lng = pixel_to_lat_lng(
|
|
|
|
|
|
|
|
|
|
| 105 |
if lat_lng:
|
| 106 |
enriched["latLng"] = lat_lng
|
| 107 |
bbox = region.get("bbox") or {}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
else:
|
| 112 |
enriched["latLng"] = None
|
| 113 |
out.append(enriched)
|
|
@@ -125,4 +206,3 @@ def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
|
|
| 125 |
return float(lat), float(lng)
|
| 126 |
except (TypeError, ValueError):
|
| 127 |
return None, None
|
| 128 |
-
|
|
|
|
| 3 |
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
from .change_type_map import enrich_region_for_dda
|
| 13 |
+
from .geotiff_io import GeorefInfo, inspect_image, pixel_to_geo_wgs84, read_georef
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
|
| 18 |
|
| 19 |
|
| 20 |
+
@dataclass
|
| 21 |
+
class GeoContext:
|
| 22 |
+
bounds: Optional[BoundsWGS84]
|
| 23 |
+
georef: Optional[GeorefInfo]
|
| 24 |
+
georef_width: int
|
| 25 |
+
georef_height: int
|
| 26 |
+
|
| 27 |
+
|
| 28 |
def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
|
| 29 |
if bounds is None:
|
| 30 |
return None
|
|
|
|
| 60 |
return None
|
| 61 |
|
| 62 |
|
| 63 |
+
def resolve_geo_context(
|
| 64 |
+
db: Session,
|
| 65 |
+
base_path: str,
|
| 66 |
+
base_file: Path,
|
| 67 |
+
) -> GeoContext:
|
| 68 |
+
"""Resolve bounds and affine georef for detection geo enrichment."""
|
| 69 |
+
georef = read_georef(base_file)
|
| 70 |
+
bounds = georef.bounds_wgs84 if georef else None
|
| 71 |
+
georef_width = georef.width if georef else 0
|
| 72 |
+
georef_height = georef.height if georef else 0
|
| 73 |
+
|
| 74 |
+
if not bounds:
|
| 75 |
+
from .tree.image_service import get_image_by_file_path
|
| 76 |
+
|
| 77 |
+
rel = base_path.replace("\\", "/").strip().lstrip("/")
|
| 78 |
+
img = get_image_by_file_path(db, rel)
|
| 79 |
+
if img and img.bounds_json:
|
| 80 |
+
bounds = parse_bounds(img.bounds_json)
|
| 81 |
+
|
| 82 |
+
if not bounds:
|
| 83 |
+
bounds = bounds_from_image_path(base_file)
|
| 84 |
+
|
| 85 |
+
if georef is None and bounds:
|
| 86 |
+
meta = inspect_image(base_file)
|
| 87 |
+
georef_width = meta.width or georef_width
|
| 88 |
+
georef_height = meta.height or georef_height
|
| 89 |
+
|
| 90 |
+
return GeoContext(
|
| 91 |
+
bounds=bounds,
|
| 92 |
+
georef=georef,
|
| 93 |
+
georef_width=georef_width or 0,
|
| 94 |
+
georef_height=georef_height or 0,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def pixel_to_lat_lng(
|
| 99 |
x: float,
|
| 100 |
y: float,
|
| 101 |
img_width: int,
|
| 102 |
img_height: int,
|
| 103 |
bounds: BoundsWGS84,
|
| 104 |
+
*,
|
| 105 |
+
geo: Optional[GeoContext] = None,
|
| 106 |
) -> Optional[Dict[str, float]]:
|
| 107 |
+
if img_width <= 0 or img_height <= 0:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
if geo and geo.georef:
|
| 111 |
+
coords = pixel_to_geo_wgs84(
|
| 112 |
+
x, y, geo.georef,
|
| 113 |
+
detection_width=img_width,
|
| 114 |
+
detection_height=img_height,
|
| 115 |
+
)
|
| 116 |
+
if coords:
|
| 117 |
+
lng, lat = coords
|
| 118 |
+
return {"lat": round(lat, 6), "lng": round(lng, 6)}
|
| 119 |
+
|
| 120 |
+
if not bounds:
|
| 121 |
return None
|
| 122 |
+
|
| 123 |
west, south, east, north = bounds
|
| 124 |
+
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
|
| 125 |
+
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
|
| 126 |
+
scale_x = ref_w / float(img_width)
|
| 127 |
+
scale_y = ref_h / float(img_height)
|
| 128 |
+
px = float(x) * scale_x
|
| 129 |
+
py = float(y) * scale_y
|
| 130 |
+
lng = west + (px / ref_w) * (east - west)
|
| 131 |
+
lat = north - (py / ref_h) * (north - south)
|
| 132 |
return {"lat": round(lat, 6), "lng": round(lng, 6)}
|
| 133 |
|
| 134 |
|
|
|
|
| 137 |
img_width: int,
|
| 138 |
img_height: int,
|
| 139 |
bounds: BoundsWGS84,
|
| 140 |
+
*,
|
| 141 |
+
geo: Optional[GeoContext] = None,
|
| 142 |
) -> Optional[float]:
|
| 143 |
"""Approximate region area in square metres using geographic bounds."""
|
| 144 |
if img_width <= 0 or img_height <= 0 or not bounds:
|
| 145 |
return None
|
| 146 |
west, south, east, north = bounds
|
| 147 |
+
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
|
| 148 |
+
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
|
| 149 |
+
scale_x = ref_w / float(img_width)
|
| 150 |
+
scale_y = ref_h / float(img_height)
|
| 151 |
+
m_per_px_x = abs(east - west) / ref_w
|
| 152 |
+
m_per_px_y = abs(north - south) / ref_h
|
| 153 |
import math
|
| 154 |
mid_lat = (north + south) / 2.0
|
| 155 |
lat_scale = 111_320.0
|
| 156 |
lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
|
| 157 |
+
w_m = bbox.get("w", 0) * scale_x * m_per_px_x * lng_scale
|
| 158 |
+
h_m = bbox.get("h", 0) * scale_y * m_per_px_y * lat_scale
|
| 159 |
return round(w_m * h_m, 1)
|
| 160 |
|
| 161 |
|
|
|
|
| 165 |
img_width: int,
|
| 166 |
img_height: int,
|
| 167 |
bounds: Optional[BoundsWGS84],
|
| 168 |
+
geo: Optional[GeoContext] = None,
|
| 169 |
) -> List[dict]:
|
| 170 |
"""Add latLng, areaSqM, and DDA change type to each region."""
|
| 171 |
+
effective_bounds = bounds or (geo.bounds if geo else None)
|
| 172 |
out = []
|
| 173 |
for region in regions:
|
| 174 |
enriched = enrich_region_for_dda(region)
|
| 175 |
center = region.get("center") or {}
|
| 176 |
cx = center.get("x", 0)
|
| 177 |
cy = center.get("y", 0)
|
| 178 |
+
if effective_bounds or (geo and geo.georef):
|
| 179 |
+
lat_lng = pixel_to_lat_lng(
|
| 180 |
+
cx, cy, img_width, img_height, effective_bounds or (0, 0, 0, 0),
|
| 181 |
+
geo=geo,
|
| 182 |
+
)
|
| 183 |
if lat_lng:
|
| 184 |
enriched["latLng"] = lat_lng
|
| 185 |
bbox = region.get("bbox") or {}
|
| 186 |
+
if effective_bounds:
|
| 187 |
+
area_sq_m = bbox_area_sq_m(
|
| 188 |
+
bbox, img_width, img_height, effective_bounds, geo=geo,
|
| 189 |
+
)
|
| 190 |
+
if area_sq_m is not None:
|
| 191 |
+
enriched["areaSqM"] = area_sq_m
|
| 192 |
else:
|
| 193 |
enriched["latLng"] = None
|
| 194 |
out.append(enriched)
|
|
|
|
| 206 |
return float(lat), float(lng)
|
| 207 |
except (TypeError, ValueError):
|
| 208 |
return None, None
|
|
|
app/dda/geotiff_io.py
CHANGED
|
@@ -5,7 +5,7 @@ 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 |
|
|
@@ -165,6 +165,76 @@ def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512)
|
|
| 165 |
write_placeholder_png(dest_path, src_path.name, max_side)
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
|
| 169 |
if not bounds:
|
| 170 |
return ""
|
|
|
|
| 5 |
import logging
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Any, Optional, Tuple
|
| 9 |
|
| 10 |
from PIL import Image
|
| 11 |
|
|
|
|
| 165 |
write_placeholder_png(dest_path, src_path.name, max_side)
|
| 166 |
|
| 167 |
|
| 168 |
+
@dataclass
|
| 169 |
+
class GeorefInfo:
|
| 170 |
+
transform: Any
|
| 171 |
+
crs: Any
|
| 172 |
+
width: int
|
| 173 |
+
height: int
|
| 174 |
+
bounds_wgs84: Optional[Tuple[float, float, float, float]]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def read_georef(path: Path) -> Optional[GeorefInfo]:
|
| 178 |
+
"""Read raster affine transform and WGS84 bounds when rasterio is available."""
|
| 179 |
+
ext = path.suffix.lower()
|
| 180 |
+
if ext not in (".tif", ".tiff"):
|
| 181 |
+
return None
|
| 182 |
+
try:
|
| 183 |
+
import rasterio
|
| 184 |
+
from rasterio.warp import transform_bounds
|
| 185 |
+
|
| 186 |
+
with rasterio.open(path) as src:
|
| 187 |
+
if src.crs is None:
|
| 188 |
+
return None
|
| 189 |
+
bounds = None
|
| 190 |
+
try:
|
| 191 |
+
w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
|
| 192 |
+
bounds = (float(w), float(s), float(e), float(n))
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
logger.warning("Could not transform bounds to WGS84 for %s: %s", path.name, exc)
|
| 195 |
+
return GeorefInfo(
|
| 196 |
+
transform=src.transform,
|
| 197 |
+
crs=src.crs,
|
| 198 |
+
width=int(src.width),
|
| 199 |
+
height=int(src.height),
|
| 200 |
+
bounds_wgs84=bounds,
|
| 201 |
+
)
|
| 202 |
+
except ImportError:
|
| 203 |
+
return None
|
| 204 |
+
except Exception as exc:
|
| 205 |
+
logger.warning("read_georef failed for %s: %s", path.name, exc)
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def pixel_to_geo_wgs84(
|
| 210 |
+
x: float,
|
| 211 |
+
y: float,
|
| 212 |
+
georef: GeorefInfo,
|
| 213 |
+
*,
|
| 214 |
+
detection_width: int,
|
| 215 |
+
detection_height: int,
|
| 216 |
+
) -> Optional[Tuple[float, float]]:
|
| 217 |
+
"""Map detection pixel (x=col, y=row) to WGS84 (lng, lat)."""
|
| 218 |
+
if detection_width <= 0 or detection_height <= 0:
|
| 219 |
+
return None
|
| 220 |
+
try:
|
| 221 |
+
from rasterio.transform import xy as transform_xy
|
| 222 |
+
from rasterio.warp import transform as warp_transform
|
| 223 |
+
|
| 224 |
+
scale_x = georef.width / float(detection_width)
|
| 225 |
+
scale_y = georef.height / float(detection_height)
|
| 226 |
+
col = float(x) * scale_x
|
| 227 |
+
row = float(y) * scale_y
|
| 228 |
+
geo_x, geo_y = transform_xy(georef.transform, row, col, offset="center")
|
| 229 |
+
if georef.crs and str(georef.crs) != "EPSG:4326":
|
| 230 |
+
lngs, lats = warp_transform(georef.crs, "EPSG:4326", [geo_x], [geo_y])
|
| 231 |
+
return float(lngs[0]), float(lats[0])
|
| 232 |
+
return float(geo_x), float(geo_y)
|
| 233 |
+
except Exception as exc:
|
| 234 |
+
logger.warning("pixel_to_geo_wgs84 failed: %s", exc)
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
|
| 239 |
if not bounds:
|
| 240 |
return ""
|
app/dda/job_runner.py
CHANGED
|
@@ -88,6 +88,7 @@ def _run_job_sync(job_id: int) -> None:
|
|
| 88 |
notify_email=job.notify_email or params.get("notify_email"),
|
| 89 |
max_size=get_detection_max_side(),
|
| 90 |
geo_bounds_path=base_file,
|
|
|
|
| 91 |
user_id=job.created_by,
|
| 92 |
job_id=job_id,
|
| 93 |
)
|
|
|
|
| 88 |
notify_email=job.notify_email or params.get("notify_email"),
|
| 89 |
max_size=get_detection_max_side(),
|
| 90 |
geo_bounds_path=base_file,
|
| 91 |
+
base_path=base_path,
|
| 92 |
user_id=job.created_by,
|
| 93 |
job_id=job_id,
|
| 94 |
)
|
app/dda/local_routes.py
CHANGED
|
@@ -106,6 +106,9 @@ def local_thumb(path: str = Query(...)):
|
|
| 106 |
@router.post("/local/rescan")
|
| 107 |
def local_rescan(db: Session = Depends(get_db)):
|
| 108 |
_require_dda()
|
|
|
|
|
|
|
|
|
|
| 109 |
tree = build_tree(db)
|
| 110 |
images = list_all_images(db)
|
| 111 |
return {
|
|
@@ -113,6 +116,7 @@ def local_rescan(db: Session = Depends(get_db)):
|
|
| 113 |
"tree": tree,
|
| 114 |
"totalImages": len(images),
|
| 115 |
"storageRoot": str(get_storage_root()),
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
|
|
@@ -180,6 +184,7 @@ async def detect_from_library(
|
|
| 180 |
notify_email=notify_email,
|
| 181 |
max_size=get_detection_max_side(),
|
| 182 |
geo_bounds_path=base_file,
|
|
|
|
| 183 |
user_id=user.id,
|
| 184 |
)
|
| 185 |
except Exception as exc:
|
|
|
|
| 106 |
@router.post("/local/rescan")
|
| 107 |
def local_rescan(db: Session = Depends(get_db)):
|
| 108 |
_require_dda()
|
| 109 |
+
from .tree.sync_service import sync_from_filesystem
|
| 110 |
+
|
| 111 |
+
sync_stats = sync_from_filesystem(db)
|
| 112 |
tree = build_tree(db)
|
| 113 |
images = list_all_images(db)
|
| 114 |
return {
|
|
|
|
| 116 |
"tree": tree,
|
| 117 |
"totalImages": len(images),
|
| 118 |
"storageRoot": str(get_storage_root()),
|
| 119 |
+
"sync": sync_stats,
|
| 120 |
}
|
| 121 |
|
| 122 |
|
|
|
|
| 184 |
notify_email=notify_email,
|
| 185 |
max_size=get_detection_max_side(),
|
| 186 |
geo_bounds_path=base_file,
|
| 187 |
+
base_path=base_norm,
|
| 188 |
user_id=user.id,
|
| 189 |
)
|
| 190 |
except Exception as exc:
|
app/dda/tree/image_service.py
CHANGED
|
@@ -132,6 +132,13 @@ def list_images_for_node(db: Session, node_id: int) -> List[dict]:
|
|
| 132 |
return [image_to_dict(r, node) for r in rows]
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
def list_all_images(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None) -> List[dict]:
|
| 136 |
q = db.query(ImageLibrary, TreeNode).join(TreeNode, ImageLibrary.node_id == TreeNode.id).filter(TreeNode.is_active == True) # noqa: E712
|
| 137 |
if node_id:
|
|
|
|
| 132 |
return [image_to_dict(r, node) for r in rows]
|
| 133 |
|
| 134 |
|
| 135 |
+
def get_image_by_file_path(db: Session, relative_path: str) -> Optional[ImageLibrary]:
|
| 136 |
+
rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
|
| 137 |
+
if not rel:
|
| 138 |
+
return None
|
| 139 |
+
return db.query(ImageLibrary).filter(ImageLibrary.file_path == rel).first()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
def list_all_images(db: Session, *, node_id: Optional[int] = None, query: Optional[str] = None) -> List[dict]:
|
| 143 |
q = db.query(ImageLibrary, TreeNode).join(TreeNode, ImageLibrary.node_id == TreeNode.id).filter(TreeNode.is_active == True) # noqa: E712
|
| 144 |
if node_id:
|
app/dda/tree/sync_service.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filesystem → DB sync for tree library folders and images."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from ..config import ALLOWED_EXTENSIONS
|
| 12 |
+
from ..geotiff_io import bounds_to_json, inspect_image
|
| 13 |
+
from .audit_service import log_action
|
| 14 |
+
from .models import ImageLibrary, TreeNode
|
| 15 |
+
from .path_service import ensure_node_directory, storage_root
|
| 16 |
+
from .path_slugs import RESERVED
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_NODE_TYPES = ("Zone", "Area", "Year", "Folder")
|
| 21 |
+
_SKIP_DIRS = frozenset({".git", ".thumbs", "__pycache__", "cache", "thumbs"})
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _infer_node_type(depth: int) -> str:
|
| 25 |
+
return _NODE_TYPES[min(depth, len(_NODE_TYPES) - 1)]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _display_name(folder_slug: str) -> str:
|
| 29 |
+
name = folder_slug.replace("_", " ").replace("-", " ").strip()
|
| 30 |
+
return name or folder_slug
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _find_node_by_physical_path(db: Session, physical_path: str) -> Optional[TreeNode]:
|
| 34 |
+
rel = (physical_path or "").strip("/")
|
| 35 |
+
if not rel:
|
| 36 |
+
return None
|
| 37 |
+
return (
|
| 38 |
+
db.query(TreeNode)
|
| 39 |
+
.filter(TreeNode.physical_path == rel, TreeNode.is_active == True) # noqa: E712
|
| 40 |
+
.first()
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def ensure_node_from_disk(db: Session, physical_path: str, *, created_by: str = "filesystem-sync") -> TreeNode:
|
| 45 |
+
"""Ensure a TreeNode exists for a disk folder path (slug segments)."""
|
| 46 |
+
rel = (physical_path or "").strip("/")
|
| 47 |
+
if not rel:
|
| 48 |
+
raise ValueError("physical_path is required")
|
| 49 |
+
|
| 50 |
+
existing = _find_node_by_physical_path(db, rel)
|
| 51 |
+
if existing:
|
| 52 |
+
ensure_node_directory(rel)
|
| 53 |
+
return existing
|
| 54 |
+
|
| 55 |
+
parts = rel.split("/")
|
| 56 |
+
parent_id = None
|
| 57 |
+
parent_display = ""
|
| 58 |
+
if len(parts) > 1:
|
| 59 |
+
parent = ensure_node_from_disk(db, "/".join(parts[:-1]), created_by=created_by)
|
| 60 |
+
parent_id = parent.id
|
| 61 |
+
parent_display = parent.node_path or parent.node_name
|
| 62 |
+
|
| 63 |
+
folder_slug = parts[-1]
|
| 64 |
+
if folder_slug.lower() in RESERVED or folder_slug.lower() == "images":
|
| 65 |
+
raise ValueError(f"Reserved folder name: {folder_slug}")
|
| 66 |
+
|
| 67 |
+
sibling = (
|
| 68 |
+
db.query(TreeNode)
|
| 69 |
+
.filter(
|
| 70 |
+
TreeNode.parent_id == parent_id,
|
| 71 |
+
TreeNode.slug == folder_slug,
|
| 72 |
+
TreeNode.is_active == True, # noqa: E712
|
| 73 |
+
)
|
| 74 |
+
.first()
|
| 75 |
+
)
|
| 76 |
+
if sibling:
|
| 77 |
+
ensure_node_directory(sibling.physical_path)
|
| 78 |
+
return sibling
|
| 79 |
+
|
| 80 |
+
node_name = _display_name(folder_slug)
|
| 81 |
+
node_path = f"{parent_display}/{node_name}".strip("/") if parent_display else node_name
|
| 82 |
+
node = TreeNode(
|
| 83 |
+
parent_id=parent_id,
|
| 84 |
+
node_name=node_name,
|
| 85 |
+
node_type=_infer_node_type(len(parts) - 1),
|
| 86 |
+
node_level=max(0, len(parts) - 1),
|
| 87 |
+
node_path=node_path,
|
| 88 |
+
slug=folder_slug,
|
| 89 |
+
physical_path=rel,
|
| 90 |
+
created_by=created_by,
|
| 91 |
+
)
|
| 92 |
+
db.add(node)
|
| 93 |
+
db.flush()
|
| 94 |
+
ensure_node_directory(rel)
|
| 95 |
+
log_action(
|
| 96 |
+
db,
|
| 97 |
+
"sync_create",
|
| 98 |
+
node_id=node.id,
|
| 99 |
+
new_value={"name": node_name, "path": node_path, "physical": rel},
|
| 100 |
+
action_by=created_by,
|
| 101 |
+
)
|
| 102 |
+
db.commit()
|
| 103 |
+
db.refresh(node)
|
| 104 |
+
logger.info("Synced tree node from disk: %s", rel)
|
| 105 |
+
return node
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _index_image_file(db: Session, node: TreeNode, file_path: Path, rel_file: str) -> bool:
|
| 109 |
+
existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
|
| 110 |
+
stat = file_path.stat()
|
| 111 |
+
if existing:
|
| 112 |
+
changed = (
|
| 113 |
+
existing.file_size_bytes != stat.st_size
|
| 114 |
+
or existing.node_id != node.id
|
| 115 |
+
)
|
| 116 |
+
if changed:
|
| 117 |
+
meta = inspect_image(file_path)
|
| 118 |
+
existing.node_id = node.id
|
| 119 |
+
existing.file_size_bytes = stat.st_size
|
| 120 |
+
existing.width = meta.width
|
| 121 |
+
existing.height = meta.height
|
| 122 |
+
existing.has_georef = meta.has_georef
|
| 123 |
+
existing.bounds_json = bounds_to_json(meta.bounds_wgs84) or existing.bounds_json
|
| 124 |
+
existing.format = meta.format
|
| 125 |
+
db.commit()
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
meta = inspect_image(file_path)
|
| 129 |
+
img = ImageLibrary(
|
| 130 |
+
node_id=node.id,
|
| 131 |
+
image_name=file_path.name,
|
| 132 |
+
image_type="GeoTIFF" if file_path.suffix.lower() in (".tif", ".tiff") else "Raster",
|
| 133 |
+
file_path=rel_file,
|
| 134 |
+
uploaded_by="filesystem-sync",
|
| 135 |
+
file_size_bytes=stat.st_size,
|
| 136 |
+
thumb_cache_key=hashlib.sha256(rel_file.encode()).hexdigest()[:32],
|
| 137 |
+
width=meta.width,
|
| 138 |
+
height=meta.height,
|
| 139 |
+
has_georef=meta.has_georef,
|
| 140 |
+
bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
|
| 141 |
+
format=meta.format,
|
| 142 |
+
)
|
| 143 |
+
db.add(img)
|
| 144 |
+
db.commit()
|
| 145 |
+
logger.info("Indexed image from disk: %s", rel_file)
|
| 146 |
+
return True
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _sync_directory(db: Session, abs_dir: Path, rel_path: str, stats: dict) -> None:
|
| 150 |
+
"""Recursively sync nodes and images under rel_path."""
|
| 151 |
+
if not abs_dir.is_dir():
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
for child in sorted(abs_dir.iterdir()):
|
| 155 |
+
if not child.is_dir() or child.name.startswith(".") or child.name in _SKIP_DIRS:
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
child_rel = f"{rel_path}/{child.name}".strip("/") if rel_path else child.name
|
| 159 |
+
|
| 160 |
+
if child.name.lower() == "images":
|
| 161 |
+
if rel_path:
|
| 162 |
+
node = _find_node_by_physical_path(db, rel_path)
|
| 163 |
+
if not node:
|
| 164 |
+
node = ensure_node_from_disk(db, rel_path)
|
| 165 |
+
for f in sorted(child.iterdir()):
|
| 166 |
+
if not f.is_file() or f.suffix.lower() not in ALLOWED_EXTENSIONS:
|
| 167 |
+
continue
|
| 168 |
+
rel_file = f.relative_to(storage_root()).as_posix()
|
| 169 |
+
if _index_image_file(db, node, f, rel_file):
|
| 170 |
+
stats["imagesIndexed"] += 1
|
| 171 |
+
else:
|
| 172 |
+
stats["imagesUpdated"] += 1
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
before = _find_node_by_physical_path(db, child_rel)
|
| 176 |
+
ensure_node_from_disk(db, child_rel)
|
| 177 |
+
if not before:
|
| 178 |
+
stats["nodesCreated"] += 1
|
| 179 |
+
_sync_directory(db, child, child_rel, stats)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def sync_from_filesystem(db: Session) -> dict:
|
| 183 |
+
"""Import disk folders/images into tree_nodes and image_library."""
|
| 184 |
+
root = storage_root()
|
| 185 |
+
stats = {
|
| 186 |
+
"nodesCreated": 0,
|
| 187 |
+
"imagesIndexed": 0,
|
| 188 |
+
"imagesUpdated": 0,
|
| 189 |
+
"orphansFlagged": 0,
|
| 190 |
+
}
|
| 191 |
+
if not root.exists():
|
| 192 |
+
return stats
|
| 193 |
+
|
| 194 |
+
_sync_directory(db, root, "", stats)
|
| 195 |
+
|
| 196 |
+
# Flag DB images whose files are missing on disk
|
| 197 |
+
for img in db.query(ImageLibrary).all():
|
| 198 |
+
full = storage_root() / img.file_path.replace("\\", "/")
|
| 199 |
+
if not full.exists():
|
| 200 |
+
stats["orphansFlagged"] += 1
|
| 201 |
+
|
| 202 |
+
logger.info("Filesystem sync complete: %s", stats)
|
| 203 |
+
return stats
|
static/js/dda/app.js
CHANGED
|
@@ -84,9 +84,11 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
|
|
| 84 |
|
| 85 |
async function rescanLibrary() {
|
| 86 |
const data = await ddaApi('POST', '/api/dda/local/rescan');
|
| 87 |
-
if (typeof
|
|
|
|
| 88 |
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 89 |
await loadLibraryImages();
|
|
|
|
| 90 |
return data;
|
| 91 |
}
|
| 92 |
|
|
@@ -203,7 +205,11 @@ document.getElementById('btn-refresh-lib')?.addEventListener('click', async () =
|
|
| 203 |
btn.disabled = true;
|
| 204 |
try {
|
| 205 |
const data = await rescanLibrary();
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
} catch (err) {
|
| 208 |
showDdaError(err.message);
|
| 209 |
} finally {
|
|
|
|
| 84 |
|
| 85 |
async function rescanLibrary() {
|
| 86 |
const data = await ddaApi('POST', '/api/dda/local/rescan');
|
| 87 |
+
if (typeof renderAllTrees === 'function') renderAllTrees({ tree: data.tree }, []);
|
| 88 |
+
else if (typeof renderTree === 'function') renderTree({ tree: data.tree }, []);
|
| 89 |
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 90 |
await loadLibraryImages();
|
| 91 |
+
if (typeof loadCompareLibraryGrid === 'function') await loadCompareLibraryGrid();
|
| 92 |
return data;
|
| 93 |
}
|
| 94 |
|
|
|
|
| 205 |
btn.disabled = true;
|
| 206 |
try {
|
| 207 |
const data = await rescanLibrary();
|
| 208 |
+
const sync = data.sync || {};
|
| 209 |
+
const parts = [`${data.totalImages || 0} image(s)`];
|
| 210 |
+
if (sync.nodesCreated) parts.push(`${sync.nodesCreated} folder(s) imported`);
|
| 211 |
+
if (sync.imagesIndexed) parts.push(`${sync.imagesIndexed} image(s) indexed`);
|
| 212 |
+
showDdaSuccess(`Library synced — ${parts.join(', ')}.`);
|
| 213 |
} catch (err) {
|
| 214 |
showDdaError(err.message);
|
| 215 |
} finally {
|
static/js/dda/compare.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/** Change Detection tab — pick library images and run comparison. */
|
| 2 |
|
| 3 |
-
const compareState = { t1: null, t2: null, pickingSlot: null };
|
| 4 |
|
| 5 |
function compareFormatBytes(n) {
|
| 6 |
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
|
@@ -92,6 +92,9 @@ function setSlot(slotKey, img) {
|
|
| 92 |
|
| 93 |
function findLibraryItem(path) {
|
| 94 |
const norm = decodePath(path);
|
|
|
|
|
|
|
|
|
|
| 95 |
const items = ensureDdaState().libraryItems || [];
|
| 96 |
return items.find((i) => i.path === norm) || {
|
| 97 |
path: norm,
|
|
@@ -101,6 +104,55 @@ function findLibraryItem(path) {
|
|
| 101 |
};
|
| 102 |
}
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
function refreshCompareLibrarySelection() {
|
| 105 |
const grid = document.getElementById('compare-lib-grid');
|
| 106 |
if (!grid) return;
|
|
@@ -113,47 +165,26 @@ function refreshCompareLibrarySelection() {
|
|
| 113 |
|
| 114 |
async function loadCompareLibraryGrid() {
|
| 115 |
const grid = document.getElementById('compare-lib-grid');
|
|
|
|
| 116 |
if (!grid) return;
|
| 117 |
|
| 118 |
grid.innerHTML = '<p class="dim">Loading library images…</p>';
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
ensureDdaState().libraryItems = items;
|
| 122 |
-
populateSelects(items);
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
|
| 138 |
-
<div class="meta">
|
| 139 |
-
<span class="dim">${escapeHtml(img.breadcrumb || img.nodePath || '')}</span><br/>
|
| 140 |
-
${safeName}<br/>
|
| 141 |
-
<span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
|
| 142 |
-
</div>
|
| 143 |
-
<div class="dda-compare-assign">
|
| 144 |
-
<button type="button" class="btn btn-secondary btn-sm" data-assign="t1" data-path="${enc}">T1</button>
|
| 145 |
-
<button type="button" class="btn btn-secondary btn-sm" data-assign="t2" data-path="${enc}">T2</button>
|
| 146 |
-
</div>
|
| 147 |
-
</div>`;
|
| 148 |
-
}).join('');
|
| 149 |
-
|
| 150 |
-
grid.querySelectorAll('.dda-compare-card').forEach((card) => {
|
| 151 |
-
card.addEventListener('dragstart', (e) => {
|
| 152 |
-
const path = decodePath(card.dataset.imagePath);
|
| 153 |
-
e.dataTransfer.setData('application/x-dda-image-path', path);
|
| 154 |
-
e.dataTransfer.setData('text/plain', path);
|
| 155 |
-
});
|
| 156 |
-
});
|
| 157 |
} catch (err) {
|
| 158 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 159 |
if (typeof showDdaError === 'function') showDdaError(err.message);
|
|
@@ -175,9 +206,19 @@ async function openPicker(slotKey) {
|
|
| 175 |
|
| 176 |
let items = ensureDdaState().libraryItems || [];
|
| 177 |
try {
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
} catch (err) {
|
| 182 |
list.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 183 |
return;
|
|
@@ -272,7 +313,23 @@ function setupCompareInteractions() {
|
|
| 272 |
});
|
| 273 |
});
|
| 274 |
|
| 275 |
-
document.getElementById('btn-compare-refresh')?.addEventListener('click', () =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
document.getElementById('dda-picker-close')?.addEventListener('click', closePicker);
|
| 277 |
document.getElementById('dda-picker-modal')?.addEventListener('click', (e) => {
|
| 278 |
if (e.target.id === 'dda-picker-modal') closePicker();
|
|
@@ -423,11 +480,31 @@ let compareInitialized = false;
|
|
| 423 |
|
| 424 |
function initCompareTab() {
|
| 425 |
if (!compareInitialized) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
setupCompareInteractions();
|
| 427 |
compareInitialized = true;
|
| 428 |
}
|
| 429 |
updateRunButton();
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
|
| 433 |
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
|
|
|
|
| 1 |
/** Change Detection tab — pick library images and run comparison. */
|
| 2 |
|
| 3 |
+
const compareState = { t1: null, t2: null, pickingSlot: null, selectedNode: null, allLibraryItems: [] };
|
| 4 |
|
| 5 |
function compareFormatBytes(n) {
|
| 6 |
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
|
|
|
| 92 |
|
| 93 |
function findLibraryItem(path) {
|
| 94 |
const norm = decodePath(path);
|
| 95 |
+
const all = compareState.allLibraryItems || [];
|
| 96 |
+
const fromAll = all.find((i) => i.path === norm);
|
| 97 |
+
if (fromAll) return fromAll;
|
| 98 |
const items = ensureDdaState().libraryItems || [];
|
| 99 |
return items.find((i) => i.path === norm) || {
|
| 100 |
path: norm,
|
|
|
|
| 104 |
};
|
| 105 |
}
|
| 106 |
|
| 107 |
+
function compareSelectionTitle() {
|
| 108 |
+
return compareState.selectedNode?.path || 'All images';
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function updateCompareFolderPath() {
|
| 112 |
+
const el = document.getElementById('compare-folder-path');
|
| 113 |
+
if (el) el.textContent = compareSelectionTitle();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function renderCompareGrid(items) {
|
| 117 |
+
const grid = document.getElementById('compare-lib-grid');
|
| 118 |
+
if (!grid) return;
|
| 119 |
+
|
| 120 |
+
if (!items.length) {
|
| 121 |
+
grid.innerHTML = `<p class="dim">No images in <strong>${escapeHtml(compareSelectionTitle())}</strong>. Select another folder or click Refresh to sync from disk.</p>`;
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
grid.innerHTML = items.map((img) => {
|
| 126 |
+
const thumb = img.thumbUrl || thumbUrlFor(img.path);
|
| 127 |
+
const enc = encodePath(img.path);
|
| 128 |
+
const t1Sel = compareState.t1?.path === img.path ? ' selected-t1' : '';
|
| 129 |
+
const t2Sel = compareState.t2?.path === img.path ? ' selected-t2' : '';
|
| 130 |
+
const safeName = img.filename.replace(/</g, '<');
|
| 131 |
+
return `
|
| 132 |
+
<div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
|
| 133 |
+
${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
|
| 134 |
+
<div class="meta">
|
| 135 |
+
<span class="dim">${escapeHtml(img.breadcrumb || img.nodePath || '')}</span><br/>
|
| 136 |
+
${safeName}<br/>
|
| 137 |
+
<span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="dda-compare-assign">
|
| 140 |
+
<button type="button" class="btn btn-secondary btn-sm" data-assign="t1" data-path="${enc}">T1</button>
|
| 141 |
+
<button type="button" class="btn btn-secondary btn-sm" data-assign="t2" data-path="${enc}">T2</button>
|
| 142 |
+
</div>
|
| 143 |
+
</div>`;
|
| 144 |
+
}).join('');
|
| 145 |
+
|
| 146 |
+
grid.querySelectorAll('.dda-compare-card').forEach((card) => {
|
| 147 |
+
card.addEventListener('dragstart', (e) => {
|
| 148 |
+
const path = decodePath(card.dataset.imagePath);
|
| 149 |
+
e.dataTransfer.setData('application/x-dda-image-path', path);
|
| 150 |
+
e.dataTransfer.setData('text/plain', path);
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
refreshCompareLibrarySelection();
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
function refreshCompareLibrarySelection() {
|
| 157 |
const grid = document.getElementById('compare-lib-grid');
|
| 158 |
if (!grid) return;
|
|
|
|
| 165 |
|
| 166 |
async function loadCompareLibraryGrid() {
|
| 167 |
const grid = document.getElementById('compare-lib-grid');
|
| 168 |
+
const title = document.getElementById('compare-grid-title');
|
| 169 |
if (!grid) return;
|
| 170 |
|
| 171 |
grid.innerHTML = '<p class="dim">Loading library images…</p>';
|
| 172 |
+
if (title) title.textContent = `Pick from library — ${compareSelectionTitle()}`;
|
| 173 |
+
updateCompareFolderPath();
|
|
|
|
|
|
|
| 174 |
|
| 175 |
+
try {
|
| 176 |
+
const allItems = await ddaApi('GET', '/api/dda/local/images');
|
| 177 |
+
compareState.allLibraryItems = allItems;
|
| 178 |
+
populateSelects(allItems);
|
| 179 |
+
|
| 180 |
+
const params = new URLSearchParams();
|
| 181 |
+
if (compareState.selectedNode?.id) params.set('node_id', String(compareState.selectedNode.id));
|
| 182 |
+
const gridItems = compareState.selectedNode?.id
|
| 183 |
+
? await ddaApi('GET', '/api/dda/local/images?' + params.toString())
|
| 184 |
+
: allItems;
|
| 185 |
+
|
| 186 |
+
ensureDdaState().libraryItems = gridItems;
|
| 187 |
+
renderCompareGrid(gridItems);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
} catch (err) {
|
| 189 |
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 190 |
if (typeof showDdaError === 'function') showDdaError(err.message);
|
|
|
|
| 206 |
|
| 207 |
let items = ensureDdaState().libraryItems || [];
|
| 208 |
try {
|
| 209 |
+
if (compareState.selectedNode?.id) {
|
| 210 |
+
const params = new URLSearchParams();
|
| 211 |
+
params.set('node_id', String(compareState.selectedNode.id));
|
| 212 |
+
items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
|
| 213 |
+
} else {
|
| 214 |
+
items = compareState.allLibraryItems.length
|
| 215 |
+
? compareState.allLibraryItems
|
| 216 |
+
: await ddaApi('GET', '/api/dda/local/images');
|
| 217 |
+
compareState.allLibraryItems = items;
|
| 218 |
+
}
|
| 219 |
+
populateSelects(compareState.allLibraryItems.length
|
| 220 |
+
? compareState.allLibraryItems
|
| 221 |
+
: items);
|
| 222 |
} catch (err) {
|
| 223 |
list.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 224 |
return;
|
|
|
|
| 313 |
});
|
| 314 |
});
|
| 315 |
|
| 316 |
+
document.getElementById('btn-compare-refresh')?.addEventListener('click', async () => {
|
| 317 |
+
const btn = document.getElementById('btn-compare-refresh');
|
| 318 |
+
btn.disabled = true;
|
| 319 |
+
try {
|
| 320 |
+
const data = await window.ddaState.rescan();
|
| 321 |
+
const sync = data.sync || {};
|
| 322 |
+
const parts = [`${data.totalImages || 0} image(s)`];
|
| 323 |
+
if (sync.nodesCreated) parts.push(`${sync.nodesCreated} folder(s) imported`);
|
| 324 |
+
if (sync.imagesIndexed) parts.push(`${sync.imagesIndexed} image(s) indexed`);
|
| 325 |
+
if (typeof showDdaSuccess === 'function') showDdaSuccess(`Library synced — ${parts.join(', ')}.`);
|
| 326 |
+
await loadCompareLibraryGrid();
|
| 327 |
+
} catch (err) {
|
| 328 |
+
if (typeof showDdaError === 'function') showDdaError(err.message);
|
| 329 |
+
} finally {
|
| 330 |
+
btn.disabled = false;
|
| 331 |
+
}
|
| 332 |
+
});
|
| 333 |
document.getElementById('dda-picker-close')?.addEventListener('click', closePicker);
|
| 334 |
document.getElementById('dda-picker-modal')?.addEventListener('click', (e) => {
|
| 335 |
if (e.target.id === 'dda-picker-modal') closePicker();
|
|
|
|
| 480 |
|
| 481 |
function initCompareTab() {
|
| 482 |
if (!compareInitialized) {
|
| 483 |
+
if (typeof registerCompareTreeSidebar === 'function') {
|
| 484 |
+
registerCompareTreeSidebar({
|
| 485 |
+
containerId: 'compare-tree',
|
| 486 |
+
searchId: 'compare-tree-search',
|
| 487 |
+
allBtnId: 'btn-compare-tree-all',
|
| 488 |
+
getSelectedNode: () => compareState.selectedNode,
|
| 489 |
+
onNodeSelect: (node) => {
|
| 490 |
+
compareState.selectedNode = node;
|
| 491 |
+
loadCompareLibraryGrid();
|
| 492 |
+
},
|
| 493 |
+
onClearNode: () => {
|
| 494 |
+
compareState.selectedNode = null;
|
| 495 |
+
loadCompareLibraryGrid();
|
| 496 |
+
},
|
| 497 |
+
});
|
| 498 |
+
}
|
| 499 |
setupCompareInteractions();
|
| 500 |
compareInitialized = true;
|
| 501 |
}
|
| 502 |
updateRunButton();
|
| 503 |
+
if (typeof loadTree === 'function') {
|
| 504 |
+
loadTree().then(() => loadCompareLibraryGrid()).catch(() => loadCompareLibraryGrid());
|
| 505 |
+
} else {
|
| 506 |
+
loadCompareLibraryGrid();
|
| 507 |
+
}
|
| 508 |
}
|
| 509 |
|
| 510 |
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
|
static/js/dda/tree.js
CHANGED
|
@@ -3,14 +3,39 @@
|
|
| 3 |
let treeData = null;
|
| 4 |
let imageTypes = [];
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
function isAdmin() {
|
| 7 |
return window.ddaState?.userRole === 'admin';
|
| 8 |
}
|
| 9 |
|
| 10 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
if (!nodes || !nodes.length) return '';
|
| 12 |
-
const filter = (document.getElementById(
|
| 13 |
-
const sel =
|
| 14 |
|
| 15 |
return nodes.map((node) => {
|
| 16 |
const name = node.name || node.nodeName;
|
|
@@ -29,12 +54,12 @@ function renderTreeNodes(nodes, depth = 0) {
|
|
| 29 |
|
| 30 |
if (hasKids) {
|
| 31 |
return `
|
| 32 |
-
<details class="dda-tree-node-wrap" open
|
| 33 |
<summary class="dda-tree-node-summary ${active ? 'active' : ''}" data-node-id="${node.id}">
|
| 34 |
<button type="button" class="dda-tree-node-btn ${active ? 'active' : ''}" data-node-id="${node.id}"
|
| 35 |
data-node-path="${escapeHtml(node.nodePath || name)}">${escapeHtml(name)}${count}</button>
|
| 36 |
</summary>
|
| 37 |
-
<div class="dda-tree-children">${renderTreeNodes(children, depth + 1)}</div>
|
| 38 |
</details>`;
|
| 39 |
}
|
| 40 |
return `
|
|
@@ -43,49 +68,64 @@ function renderTreeNodes(nodes, depth = 0) {
|
|
| 43 |
}).join('');
|
| 44 |
}
|
| 45 |
|
| 46 |
-
function
|
| 47 |
-
const el = document.getElementById(
|
| 48 |
if (!el) return;
|
| 49 |
-
treeData = tree;
|
| 50 |
if (types) imageTypes = types;
|
| 51 |
|
| 52 |
const nodes = tree?.tree || tree || [];
|
| 53 |
-
const
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
if (!nodes.length) {
|
| 57 |
-
html += '<p class="dim">No nodes yet.
|
| 58 |
}
|
| 59 |
el.innerHTML = html;
|
| 60 |
|
| 61 |
-
document.getElementById(
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
renderTree(treeData, imageTypes);
|
| 65 |
-
syncUploadNodeSelect();
|
| 66 |
});
|
| 67 |
|
| 68 |
el.querySelectorAll('.dda-tree-node-btn').forEach((btn) => {
|
| 69 |
btn.addEventListener('click', (e) => {
|
| 70 |
e.stopPropagation();
|
| 71 |
-
|
| 72 |
id: parseInt(btn.dataset.nodeId, 10),
|
| 73 |
path: btn.dataset.nodePath,
|
| 74 |
});
|
| 75 |
-
|
| 76 |
-
renderTree(treeData, imageTypes);
|
| 77 |
-
syncUploadNodeSelect();
|
| 78 |
});
|
| 79 |
});
|
| 80 |
}
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
document.getElementById('lib-tree-search')?.addEventListener('input', () => {
|
| 83 |
-
if (treeData)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
});
|
| 85 |
|
| 86 |
async function loadTree() {
|
| 87 |
const data = await ddaApi('GET', '/api/dda/tree');
|
| 88 |
-
|
| 89 |
populateNodeSelects(data.tree);
|
| 90 |
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 91 |
return data;
|
|
@@ -93,6 +133,8 @@ async function loadTree() {
|
|
| 93 |
|
| 94 |
window.loadTree = loadTree;
|
| 95 |
window.renderTree = renderTree;
|
|
|
|
|
|
|
| 96 |
|
| 97 |
function flattenNodes(nodes, out = []) {
|
| 98 |
(nodes || []).forEach((n) => {
|
|
|
|
| 3 |
let treeData = null;
|
| 4 |
let imageTypes = [];
|
| 5 |
|
| 6 |
+
const librarySidebar = {
|
| 7 |
+
containerId: 'lib-tree',
|
| 8 |
+
searchId: 'lib-tree-search',
|
| 9 |
+
allBtnId: 'btn-tree-all',
|
| 10 |
+
getSelectedNode: () => window.ddaState?.selectedNode,
|
| 11 |
+
onNodeSelect: (node) => {
|
| 12 |
+
window.ddaState.setNode(node);
|
| 13 |
+
window.ddaState.refreshImages();
|
| 14 |
+
syncUploadNodeSelect();
|
| 15 |
+
},
|
| 16 |
+
onClearNode: () => {
|
| 17 |
+
window.ddaState.clearNode();
|
| 18 |
+
window.ddaState.refreshImages();
|
| 19 |
+
syncUploadNodeSelect();
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
let compareSidebar = null;
|
| 24 |
+
|
| 25 |
function isAdmin() {
|
| 26 |
return window.ddaState?.userRole === 'admin';
|
| 27 |
}
|
| 28 |
|
| 29 |
+
function getSidebars() {
|
| 30 |
+
const list = [librarySidebar];
|
| 31 |
+
if (compareSidebar) list.push(compareSidebar);
|
| 32 |
+
return list;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function renderTreeNodes(nodes, sidebar, depth = 0) {
|
| 36 |
if (!nodes || !nodes.length) return '';
|
| 37 |
+
const filter = (document.getElementById(sidebar.searchId)?.value || '').toLowerCase();
|
| 38 |
+
const sel = sidebar.getSelectedNode?.();
|
| 39 |
|
| 40 |
return nodes.map((node) => {
|
| 41 |
const name = node.name || node.nodeName;
|
|
|
|
| 54 |
|
| 55 |
if (hasKids) {
|
| 56 |
return `
|
| 57 |
+
<details class="dda-tree-node-wrap" open>
|
| 58 |
<summary class="dda-tree-node-summary ${active ? 'active' : ''}" data-node-id="${node.id}">
|
| 59 |
<button type="button" class="dda-tree-node-btn ${active ? 'active' : ''}" data-node-id="${node.id}"
|
| 60 |
data-node-path="${escapeHtml(node.nodePath || name)}">${escapeHtml(name)}${count}</button>
|
| 61 |
</summary>
|
| 62 |
+
<div class="dda-tree-children">${renderTreeNodes(children, sidebar, depth + 1)}</div>
|
| 63 |
</details>`;
|
| 64 |
}
|
| 65 |
return `
|
|
|
|
| 68 |
}).join('');
|
| 69 |
}
|
| 70 |
|
| 71 |
+
function renderTreeSidebar(sidebar, tree, types) {
|
| 72 |
+
const el = document.getElementById(sidebar.containerId);
|
| 73 |
if (!el) return;
|
|
|
|
| 74 |
if (types) imageTypes = types;
|
| 75 |
|
| 76 |
const nodes = tree?.tree || tree || [];
|
| 77 |
+
const sel = sidebar.getSelectedNode?.();
|
| 78 |
+
const allActive = !sel?.id;
|
| 79 |
+
const allBtnId = sidebar.allBtnId || `btn-tree-all-${sidebar.containerId}`;
|
| 80 |
+
let html = `<button type="button" class="dda-tree-all ${allActive ? 'active' : ''}" id="${allBtnId}">All images</button>`;
|
| 81 |
+
html += renderTreeNodes(nodes, sidebar);
|
| 82 |
if (!nodes.length) {
|
| 83 |
+
html += '<p class="dim">No nodes yet. Create folders on disk or use Manage to add zones.</p>';
|
| 84 |
}
|
| 85 |
el.innerHTML = html;
|
| 86 |
|
| 87 |
+
document.getElementById(allBtnId)?.addEventListener('click', () => {
|
| 88 |
+
sidebar.onClearNode?.();
|
| 89 |
+
renderAllTrees(treeData, imageTypes);
|
|
|
|
|
|
|
| 90 |
});
|
| 91 |
|
| 92 |
el.querySelectorAll('.dda-tree-node-btn').forEach((btn) => {
|
| 93 |
btn.addEventListener('click', (e) => {
|
| 94 |
e.stopPropagation();
|
| 95 |
+
sidebar.onNodeSelect?.({
|
| 96 |
id: parseInt(btn.dataset.nodeId, 10),
|
| 97 |
path: btn.dataset.nodePath,
|
| 98 |
});
|
| 99 |
+
renderAllTrees(treeData, imageTypes);
|
|
|
|
|
|
|
| 100 |
});
|
| 101 |
});
|
| 102 |
}
|
| 103 |
|
| 104 |
+
function renderAllTrees(tree, types) {
|
| 105 |
+
if (tree) treeData = tree;
|
| 106 |
+
getSidebars().forEach((sidebar) => renderTreeSidebar(sidebar, treeData, types));
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function renderTree(tree, types) {
|
| 110 |
+
renderAllTrees(tree, types);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function registerCompareTreeSidebar(config) {
|
| 114 |
+
compareSidebar = config;
|
| 115 |
+
if (treeData) renderAllTrees(treeData, imageTypes);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
document.getElementById('lib-tree-search')?.addEventListener('input', () => {
|
| 119 |
+
if (treeData) renderAllTrees(treeData, imageTypes);
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
document.getElementById('compare-tree-search')?.addEventListener('input', () => {
|
| 123 |
+
if (treeData) renderAllTrees(treeData, imageTypes);
|
| 124 |
});
|
| 125 |
|
| 126 |
async function loadTree() {
|
| 127 |
const data = await ddaApi('GET', '/api/dda/tree');
|
| 128 |
+
renderAllTrees(data, data.imageTypes);
|
| 129 |
populateNodeSelects(data.tree);
|
| 130 |
if (typeof populateManageNodeSelect === 'function') populateManageNodeSelect();
|
| 131 |
return data;
|
|
|
|
| 133 |
|
| 134 |
window.loadTree = loadTree;
|
| 135 |
window.renderTree = renderTree;
|
| 136 |
+
window.renderAllTrees = renderAllTrees;
|
| 137 |
+
window.registerCompareTreeSidebar = registerCompareTreeSidebar;
|
| 138 |
|
| 139 |
function flattenNodes(nodes, out = []) {
|
| 140 |
(nodes || []).forEach((n) => {
|
templates/index_dda.html
CHANGED
|
@@ -103,66 +103,78 @@
|
|
| 103 |
|
| 104 |
<!-- Tab: Change Detection -->
|
| 105 |
<section id="tab-detect" class="dda-panel" role="tabpanel">
|
| 106 |
-
<div class="
|
| 107 |
-
<
|
| 108 |
-
<p class="sub">Select <strong>Base (T1)</strong> and <strong>Comparison (T2)</strong> from the library, then run detection.</p>
|
| 109 |
-
<div class="dda-compare-slots">
|
| 110 |
-
<div class="dda-slot" id="slot-t1" data-slot="t1">
|
| 111 |
-
<span class="dda-slot-label">Base Image (T1)</span>
|
| 112 |
-
<div id="slot-t1-preview" class="dda-slot-preview-wrap"></div>
|
| 113 |
-
<select id="select-t1" class="dda-image-select" aria-label="Select base image">
|
| 114 |
-
<option value="">— Choose base image —</option>
|
| 115 |
-
</select>
|
| 116 |
-
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t1">Browse library</button>
|
| 117 |
-
</div>
|
| 118 |
-
<div class="dda-slot" id="slot-t2" data-slot="t2">
|
| 119 |
-
<span class="dda-slot-label">Comparison Image (T2)</span>
|
| 120 |
-
<div id="slot-t2-preview" class="dda-slot-preview-wrap"></div>
|
| 121 |
-
<select id="select-t2" class="dda-image-select" aria-label="Select comparison image">
|
| 122 |
-
<option value="">— Choose comparison image —</option>
|
| 123 |
-
</select>
|
| 124 |
-
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t2">Browse library</button>
|
| 125 |
-
</div>
|
| 126 |
-
</div>
|
| 127 |
-
<div class="dda-compare-library card">
|
| 128 |
<div class="card-header">
|
| 129 |
-
<h3>
|
| 130 |
-
<button type="button" class="btn btn-secondary btn-sm" id="btn-compare-refresh">Refresh
|
| 131 |
-
</div>
|
| 132 |
-
<p class="sub dim">Click <strong>T1</strong> or <strong>T2</strong> on an image, or use the dropdowns above.</p>
|
| 133 |
-
<div id="compare-lib-grid" class="dda-grid dda-compare-grid">
|
| 134 |
-
<p class="dim">Loading library images…</p>
|
| 135 |
</div>
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
<div class="
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
</div>
|
| 155 |
-
|
| 156 |
-
<label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
|
| 157 |
-
<label class="dda-check"><input type="checkbox" id="dda-detect-notify" /> Email report when done</label>
|
| 158 |
-
<input type="email" id="dda-detect-notify-email" class="dda-notify-email hidden" placeholder="recipient@example.com" />
|
| 159 |
-
</div>
|
| 160 |
-
<p class="dim" id="dda-detect-res-hint"></p>
|
| 161 |
-
<button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
|
| 162 |
-
<div id="detect-progress" class="dda-upload-progress hidden" style="margin-top:0.75rem">
|
| 163 |
-
<div class="dda-progress-bar"><div id="detect-progress-fill" class="dda-progress-fill"></div></div>
|
| 164 |
-
<span id="detect-progress-label" class="dim">Starting detection…</span>
|
| 165 |
-
</div>
|
| 166 |
</div>
|
| 167 |
</section>
|
| 168 |
|
|
@@ -305,11 +317,11 @@
|
|
| 305 |
</div>
|
| 306 |
</div>
|
| 307 |
|
| 308 |
-
<script src="/static/js/dda/app.js?v=
|
| 309 |
-
<script src="/static/js/dda/tree.js?v=
|
| 310 |
<script src="/static/js/dda/library.js?v=8"></script>
|
| 311 |
<script src="/static/js/dda/result.js?v=6"></script>
|
| 312 |
-
<script src="/static/js/dda/compare.js?v=
|
| 313 |
<script src="/static/js/dda/reports.js?v=4"></script>
|
| 314 |
<script src="/static/js/dda/notifications.js?v=1"></script>
|
| 315 |
</body>
|
|
|
|
| 103 |
|
| 104 |
<!-- Tab: Change Detection -->
|
| 105 |
<section id="tab-detect" class="dda-panel" role="tabpanel">
|
| 106 |
+
<div class="dda-layout">
|
| 107 |
+
<aside class="dda-sidebar card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
<div class="card-header">
|
| 109 |
+
<h3>Folders</h3>
|
| 110 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-compare-refresh">Refresh</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
+
<div id="compare-folder-path" class="dda-folder-path dim">All images</div>
|
| 113 |
+
<input type="search" id="compare-tree-search" class="dda-search" placeholder="Filter nodes…" />
|
| 114 |
+
<div id="compare-tree" class="dda-tree"><p class="dim">Loading…</p></div>
|
| 115 |
+
</aside>
|
| 116 |
+
<main class="dda-main">
|
| 117 |
+
<div class="card">
|
| 118 |
+
<div class="card-header"><h3>Compare Images</h3></div>
|
| 119 |
+
<p class="sub">Select <strong>Base (T1)</strong> and <strong>Comparison (T2)</strong> from the library, then run detection.</p>
|
| 120 |
+
<div class="dda-compare-slots">
|
| 121 |
+
<div class="dda-slot" id="slot-t1" data-slot="t1">
|
| 122 |
+
<span class="dda-slot-label">Base Image (T1)</span>
|
| 123 |
+
<div id="slot-t1-preview" class="dda-slot-preview-wrap"></div>
|
| 124 |
+
<select id="select-t1" class="dda-image-select" aria-label="Select base image">
|
| 125 |
+
<option value="">— Choose base image —</option>
|
| 126 |
+
</select>
|
| 127 |
+
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t1">Browse library</button>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="dda-slot" id="slot-t2" data-slot="t2">
|
| 130 |
+
<span class="dda-slot-label">Comparison Image (T2)</span>
|
| 131 |
+
<div id="slot-t2-preview" class="dda-slot-preview-wrap"></div>
|
| 132 |
+
<select id="select-t2" class="dda-image-select" aria-label="Select comparison image">
|
| 133 |
+
<option value="">— Choose comparison image —</option>
|
| 134 |
+
</select>
|
| 135 |
+
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t2">Browse library</button>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="dda-compare-library card">
|
| 139 |
+
<div class="card-header">
|
| 140 |
+
<h3 id="compare-grid-title">Pick from library</h3>
|
| 141 |
+
</div>
|
| 142 |
+
<p class="sub dim">Click <strong>T1</strong> or <strong>T2</strong> on an image, or use the dropdowns above (all images).</p>
|
| 143 |
+
<div id="compare-lib-grid" class="dda-grid dda-compare-grid">
|
| 144 |
+
<p class="dim">Loading library images…</p>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="dda-detect-options">
|
| 148 |
+
<div class="form-group">
|
| 149 |
+
<label for="dda-detect-method">Method</label>
|
| 150 |
+
<select id="dda-detect-method">
|
| 151 |
+
<option value="AI-Based Deep Learning">AI-Based Deep Learning</option>
|
| 152 |
+
<option value="Hybrid AI">Hybrid AI</option>
|
| 153 |
+
<option value="Hybrid Approach">Hybrid Approach</option>
|
| 154 |
+
<option value="Image Difference">Image Difference</option>
|
| 155 |
+
</select>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="form-group">
|
| 158 |
+
<label for="dda-detect-sensitivity">Sensitivity (0–1)</label>
|
| 159 |
+
<input type="number" id="dda-detect-sensitivity" min="0" max="1" step="0.05" value="0.45" />
|
| 160 |
+
</div>
|
| 161 |
+
<div class="form-group">
|
| 162 |
+
<label for="dda-detect-min-area">Min region area (px)</label>
|
| 163 |
+
<input type="number" id="dda-detect-min-area" min="50" max="10000" step="10" value="150" />
|
| 164 |
+
</div>
|
| 165 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
|
| 166 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
|
| 167 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-notify" /> Email report when done</label>
|
| 168 |
+
<input type="email" id="dda-detect-notify-email" class="dda-notify-email hidden" placeholder="recipient@example.com" />
|
| 169 |
+
</div>
|
| 170 |
+
<p class="dim" id="dda-detect-res-hint"></p>
|
| 171 |
+
<button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
|
| 172 |
+
<div id="detect-progress" class="dda-upload-progress hidden" style="margin-top:0.75rem">
|
| 173 |
+
<div class="dda-progress-bar"><div id="detect-progress-fill" class="dda-progress-fill"></div></div>
|
| 174 |
+
<span id="detect-progress-label" class="dim">Starting detection…</span>
|
| 175 |
+
</div>
|
| 176 |
</div>
|
| 177 |
+
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
</section>
|
| 180 |
|
|
|
|
| 317 |
</div>
|
| 318 |
</div>
|
| 319 |
|
| 320 |
+
<script src="/static/js/dda/app.js?v=15"></script>
|
| 321 |
+
<script src="/static/js/dda/tree.js?v=2"></script>
|
| 322 |
<script src="/static/js/dda/library.js?v=8"></script>
|
| 323 |
<script src="/static/js/dda/result.js?v=6"></script>
|
| 324 |
+
<script src="/static/js/dda/compare.js?v=11"></script>
|
| 325 |
<script src="/static/js/dda/reports.js?v=4"></script>
|
| 326 |
<script src="/static/js/dda/notifications.js?v=1"></script>
|
| 327 |
</body>
|