coderuday21 Cursor commited on
Commit
15e9574
·
1 Parent(s): d9820a1

Add compare tab tree sidebar, fix geo coordinates, and sync local folders to library.

Browse files
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, **Manage** (admin) for create/rename/move/delete
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** — Select T1/T2 from tree library; Run Detection completes
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 bounds_from_image_path, enrich_regions_geo
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": int(r["center"][0]), "y": int(r["center"][1])},
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
- img_w, img_h = before_for_slider.size
162
- bounds = bounds_from_image_path(geo_bounds_path) if geo_bounds_path else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  regions_serializable = enrich_regions_geo(
164
  regions_serializable,
165
- img_width=img_w,
166
- img_height=img_h,
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 or not bounds:
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  return None
 
61
  west, south, east, north = bounds
62
- lng = west + (float(x) / img_width) * (east - west)
63
- lat = north - (float(y) / img_height) * (north - south)
 
 
 
 
 
 
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
- m_per_px_x = abs(east - west) / img_width
78
- m_per_px_y = abs(north - south) / img_height
79
- # Rough conversion: 1 degree ≈ 111_320 m at equator; scale lng by cos(lat)
 
 
 
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 bounds:
104
- lat_lng = pixel_to_lat_lng(cx, cy, img_width, img_height, bounds)
 
 
 
105
  if lat_lng:
106
  enriched["latLng"] = lat_lng
107
  bbox = region.get("bbox") or {}
108
- area_sq_m = bbox_area_sq_m(bbox, img_width, img_height, bounds)
109
- if area_sq_m is not None:
110
- enriched["areaSqM"] = area_sq_m
 
 
 
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 renderTree === 'function') renderTree({ tree: data.tree }, []);
 
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
- showDdaSuccess(`Library refreshed ${data.totalImages || 0} image(s).`);
 
 
 
 
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
- try {
120
- const items = await ddaApi('GET', '/api/dda/local/images');
121
- ensureDdaState().libraryItems = items;
122
- populateSelects(items);
123
 
124
- if (!items.length) {
125
- grid.innerHTML = '<p class="dim">No images found. Copy .tif files into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh list</strong>.</p>';
126
- return;
127
- }
128
-
129
- grid.innerHTML = items.map((img) => {
130
- const thumb = img.thumbUrl || thumbUrlFor(img.path);
131
- const enc = encodePath(img.path);
132
- const t1Sel = compareState.t1?.path === img.path ? ' selected-t1' : '';
133
- const t2Sel = compareState.t2?.path === img.path ? ' selected-t2' : '';
134
- const safeName = img.filename.replace(/</g, '&lt;');
135
- return `
136
- <div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
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
- items = await ddaApi('GET', '/api/dda/local/images');
179
- ensureDdaState().libraryItems = items;
180
- populateSelects(items);
 
 
 
 
 
 
 
 
 
 
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', () => loadCompareLibraryGrid());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- loadCompareLibraryGrid();
 
 
 
 
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, '&lt;');
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 renderTreeNodes(nodes, depth = 0) {
 
 
 
 
 
 
11
  if (!nodes || !nodes.length) return '';
12
- const filter = (document.getElementById('lib-tree-search')?.value || '').toLowerCase();
13
- const sel = window.ddaState?.selectedNode;
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${indent ? '' : ''}>
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 renderTree(tree, types) {
47
- const el = document.getElementById('lib-tree');
48
  if (!el) return;
49
- treeData = tree;
50
  if (types) imageTypes = types;
51
 
52
  const nodes = tree?.tree || tree || [];
53
- const allActive = !window.ddaState?.selectedNode?.id;
54
- let html = `<button type="button" class="dda-tree-all ${allActive ? 'active' : ''}" id="btn-tree-all">All images</button>`;
55
- html += renderTreeNodes(nodes);
 
 
56
  if (!nodes.length) {
57
- html += '<p class="dim">No nodes yet. Admins can use Manage to add zones.</p>';
58
  }
59
  el.innerHTML = html;
60
 
61
- document.getElementById('btn-tree-all')?.addEventListener('click', () => {
62
- window.ddaState.clearNode();
63
- window.ddaState.refreshImages();
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
- window.ddaState.setNode({
72
  id: parseInt(btn.dataset.nodeId, 10),
73
  path: btn.dataset.nodePath,
74
  });
75
- window.ddaState.refreshImages();
76
- renderTree(treeData, imageTypes);
77
- syncUploadNodeSelect();
78
  });
79
  });
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  document.getElementById('lib-tree-search')?.addEventListener('input', () => {
83
- if (treeData) renderTree(treeData, imageTypes);
 
 
 
 
84
  });
85
 
86
  async function loadTree() {
87
  const data = await ddaApi('GET', '/api/dda/tree');
88
- renderTree(data, data.imageTypes);
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="card">
107
- <div class="card-header"><h3>Compare Images</h3></div>
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>Pick from library</h3>
130
- <button type="button" class="btn btn-secondary btn-sm" id="btn-compare-refresh">Refresh list</button>
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
- </div>
137
- <div class="dda-detect-options">
138
- <div class="form-group">
139
- <label for="dda-detect-method">Method</label>
140
- <select id="dda-detect-method">
141
- <option value="AI-Based Deep Learning">AI-Based Deep Learning</option>
142
- <option value="Hybrid AI">Hybrid AI</option>
143
- <option value="Hybrid Approach">Hybrid Approach</option>
144
- <option value="Image Difference">Image Difference</option>
145
- </select>
146
- </div>
147
- <div class="form-group">
148
- <label for="dda-detect-sensitivity">Sensitivity (0–1)</label>
149
- <input type="number" id="dda-detect-sensitivity" min="0" max="1" step="0.05" value="0.45" />
150
- </div>
151
- <div class="form-group">
152
- <label for="dda-detect-min-area">Min region area (px)</label>
153
- <input type="number" id="dda-detect-min-area" min="50" max="10000" step="10" value="150" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  </div>
155
- <label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
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=14"></script>
309
- <script src="/static/js/dda/tree.js?v=1"></script>
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=10"></script>
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>