coderuday21 Cursor commited on
Commit
79b0691
Β·
1 Parent(s): 8c0e957

Fix region coordinates for all formats, add Google Maps button, harden JPEG

Browse files

- Resolve georeferencing for PNG/JPEG via rasterio sidecars and explicit
world-file parsing; tag georef source (embedded/worldfile/manual/linear)
- Add out-of-range affine guard with linear fallback; remove (0,0,0,0) sentinel
- Surface a geo debug block in detection statistics
- Accept optional manual WGS84 bounds at tree upload for non-georeferenced files
- Add per-region "Map" button/link opening Google Maps in results and report
- Apply EXIF transpose on JPEG load/preview/upload to avoid rotation artifacts
- Recognize JPEG/PNG image types in filesystem sync
- Raise min region area when registration is poor to cut misalignment false positives

Co-authored-by: Cursor <cursoragent@cursor.com>

app/dda/detect_service.py CHANGED
@@ -184,6 +184,19 @@ def run_detection_and_save(
184
  bounds=bounds,
185
  geo=geo_ctx,
186
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  total_px = int(stats["total_pixels"])
188
  changed_px = int(stats["changed_pixels"])
189
  change_pct = float(stats["change_percentage"])
@@ -247,6 +260,7 @@ def run_detection_and_save(
247
  "params": stats.get("params", {}),
248
  "alignmentWarning": stats.get("alignment_warning"),
249
  "registrationOk": stats.get("params", {}).get("registration_ok"),
 
250
  },
251
  "regions": regions_serializable,
252
  "overlayBase64Png": overlay_b64,
 
184
  bounds=bounds,
185
  geo=geo_ctx,
186
  )
187
+ regions_with_coords = sum(1 for r in regions_serializable if r.get("latLng"))
188
+ geo_debug = {
189
+ "source": geo_ctx.source if geo_ctx else "none",
190
+ "crs": str(geo_ctx.georef.crs) if (geo_ctx and geo_ctx.georef and geo_ctx.georef.crs) else "",
191
+ "bounds": list(bounds) if bounds else None,
192
+ "georefWidth": geo_ctx.georef_width if geo_ctx else 0,
193
+ "georefHeight": geo_ctx.georef_height if geo_ctx else 0,
194
+ "detectionWidth": det_w,
195
+ "detectionHeight": det_h,
196
+ "regionsWithCoords": regions_with_coords,
197
+ "regionsTotal": len(regions_serializable),
198
+ }
199
+ logger.info("Geo debug for run: %s", geo_debug)
200
  total_px = int(stats["total_pixels"])
201
  changed_px = int(stats["changed_pixels"])
202
  change_pct = float(stats["change_percentage"])
 
260
  "params": stats.get("params", {}),
261
  "alignmentWarning": stats.get("alignment_warning"),
262
  "registrationOk": stats.get("params", {}).get("registration_ok"),
263
+ "geo": geo_debug,
264
  },
265
  "regions": regions_serializable,
266
  "overlayBase64Png": overlay_b64,
app/dda/geo_regions.py CHANGED
@@ -23,6 +23,14 @@ class GeoContext:
23
  georef: Optional[GeorefInfo]
24
  georef_width: int
25
  georef_height: int
 
 
 
 
 
 
 
 
26
 
27
 
28
  def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
@@ -65,11 +73,16 @@ def resolve_geo_context(
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
@@ -77,12 +90,17 @@ def resolve_geo_context(
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
@@ -92,6 +110,7 @@ def resolve_geo_context(
92
  georef=georef,
93
  georef_width=georef_width or 0,
94
  georef_height=georef_height or 0,
 
95
  )
96
 
97
 
@@ -115,7 +134,10 @@ def pixel_to_lat_lng(
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
@@ -177,7 +199,7 @@ def enrich_regions_geo(
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:
 
23
  georef: Optional[GeorefInfo]
24
  georef_width: int
25
  georef_height: int
26
+ source: str = "none" # embedded | worldfile | manual | linear | none
27
+
28
+
29
+ def _bounds_in_range(b: Optional[BoundsWGS84]) -> bool:
30
+ if not b:
31
+ return False
32
+ w, s, e, n = b
33
+ return abs(w) <= 180 and abs(e) <= 180 and abs(s) <= 90 and abs(n) <= 90
34
 
35
 
36
  def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
 
73
  base_path: str,
74
  base_file: Path,
75
  ) -> GeoContext:
76
+ """Resolve bounds and affine georef for detection geo enrichment.
77
+
78
+ Priority: embedded/world-file affine georef β†’ DB manual bounds β†’ linear
79
+ bounds inferred from the image sidecars. Tracks which source was used.
80
+ """
81
  georef = read_georef(base_file)
82
  bounds = georef.bounds_wgs84 if georef else None
83
  georef_width = georef.width if georef else 0
84
  georef_height = georef.height if georef else 0
85
+ source = getattr(georef, "source", "embedded") if georef else "none"
86
 
87
  if not bounds:
88
  from .tree.image_service import get_image_by_file_path
 
90
  rel = base_path.replace("\\", "/").strip().lstrip("/")
91
  img = get_image_by_file_path(db, rel)
92
  if img and img.bounds_json:
93
+ db_bounds = parse_bounds(img.bounds_json)
94
+ if db_bounds:
95
+ bounds = db_bounds
96
+ source = "manual"
97
 
98
  if not bounds:
99
  bounds = bounds_from_image_path(base_file)
100
+ if bounds:
101
+ source = "linear"
102
 
103
+ if (georef_width <= 0 or georef_height <= 0) and bounds:
104
  meta = inspect_image(base_file)
105
  georef_width = meta.width or georef_width
106
  georef_height = meta.height or georef_height
 
110
  georef=georef,
111
  georef_width=georef_width or 0,
112
  georef_height=georef_height or 0,
113
+ source=source if bounds else "none",
114
  )
115
 
116
 
 
134
  )
135
  if coords:
136
  lng, lat = coords
137
+ # Guard against bad transforms emitting impossible coordinates
138
+ if abs(lat) <= 90 and abs(lng) <= 180:
139
+ return {"lat": round(lat, 6), "lng": round(lng, 6)}
140
+ logger.warning("Affine produced out-of-range lat/lng (%.3f,%.3f); using linear", lat, lng)
141
 
142
  if not bounds:
143
  return None
 
199
  cy = center.get("y", 0)
200
  if effective_bounds or (geo and geo.georef):
201
  lat_lng = pixel_to_lat_lng(
202
+ cx, cy, img_width, img_height, effective_bounds,
203
  geo=geo,
204
  )
205
  if lat_lng:
app/dda/geotiff_io.py CHANGED
@@ -7,12 +7,21 @@ from dataclasses import dataclass
7
  from pathlib import Path
8
  from typing import Any, Optional, Tuple
9
 
10
- from PIL import Image
11
 
12
  from .config import get_detection_max_side
13
 
14
  logger = logging.getLogger(__name__)
15
 
 
 
 
 
 
 
 
 
 
16
 
17
  @dataclass
18
  class IngestResult:
@@ -22,6 +31,66 @@ class IngestResult:
22
  crs: str
23
  bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north
24
  format: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
  def _read_with_rasterio(path: Path) -> IngestResult:
@@ -38,13 +107,15 @@ def _read_with_rasterio(path: Path) -> IngestResult:
38
  bounds = (float(w), float(s), float(e), float(n))
39
  except Exception as exc:
40
  logger.warning("Could not transform bounds to WGS84: %s", exc)
 
41
  return IngestResult(
42
  width=int(src.width),
43
  height=int(src.height),
44
  has_georef=has_georef,
45
  crs=crs,
46
  bounds_wgs84=bounds,
47
- format="geotiff",
 
48
  )
49
 
50
 
@@ -53,6 +124,15 @@ def _read_with_pillow(path: Path) -> IngestResult:
53
  w, h = img.size
54
  ext = path.suffix.lower()
55
  fmt = "geotiff" if ext in (".tif", ".tiff") else "image"
 
 
 
 
 
 
 
 
 
56
  return IngestResult(
57
  width=w,
58
  height=h,
@@ -60,18 +140,32 @@ def _read_with_pillow(path: Path) -> IngestResult:
60
  crs="",
61
  bounds_wgs84=None,
62
  format=fmt,
 
63
  )
64
 
65
 
66
  def inspect_image(path: Path) -> IngestResult:
67
- ext = path.suffix.lower()
68
- if ext in (".tif", ".tiff"):
69
- try:
70
- return _read_with_rasterio(path)
71
- except ImportError:
72
- logger.warning("rasterio not installed β€” GeoTIFF metadata limited")
73
- except Exception as exc:
74
- logger.warning("rasterio read failed (%s), falling back to Pillow", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  return _read_with_pillow(path)
76
 
77
 
@@ -132,6 +226,7 @@ def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image:
132
  except Exception as exc:
133
  raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc
134
  with Image.open(path) as img:
 
135
  img = img.convert("RGB")
136
  if max(img.size) > max_side:
137
  img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
@@ -156,6 +251,7 @@ def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512)
156
 
157
  try:
158
  with Image.open(src_path) as img:
 
159
  img = img.convert("RGB")
160
  img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
161
  dest_path.parent.mkdir(parents=True, exist_ok=True)
@@ -172,38 +268,53 @@ class GeorefInfo:
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(
 
7
  from pathlib import Path
8
  from typing import Any, Optional, Tuple
9
 
10
+ from PIL import Image, ImageOps
11
 
12
  from .config import get_detection_max_side
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
+ # World-file extensions per image type (ESRI convention + generic .wld)
17
+ _WORLD_FILE_EXTS = {
18
+ ".tif": (".tfw", ".tifw", ".wld"),
19
+ ".tiff": (".tfw", ".tifw", ".wld"),
20
+ ".jpg": (".jgw", ".jpgw", ".wld"),
21
+ ".jpeg": (".jgw", ".jpgw", ".wld"),
22
+ ".png": (".pgw", ".pngw", ".wld"),
23
+ }
24
+
25
 
26
  @dataclass
27
  class IngestResult:
 
31
  crs: str
32
  bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north
33
  format: str
34
+ georef_source: str = "none" # embedded | worldfile | none
35
+
36
+
37
+ def _find_world_file(path: Path) -> Optional[Path]:
38
+ """Locate an ESRI world file or generic .wld sidecar next to an image."""
39
+ ext = path.suffix.lower()
40
+ candidates = []
41
+ for wext in _WORLD_FILE_EXTS.get(ext, (".wld",)):
42
+ candidates.append(path.with_suffix(wext))
43
+ # e.g. photo.jpg.wld
44
+ candidates.append(Path(str(path) + ".wld"))
45
+ for cand in candidates:
46
+ if cand.is_file():
47
+ return cand
48
+ return None
49
+
50
+
51
+ def _read_prj_crs(path: Path):
52
+ """Read a .prj sidecar (WKT) and return a rasterio CRS, or default EPSG:4326."""
53
+ try:
54
+ from rasterio.crs import CRS
55
+ except Exception:
56
+ return None
57
+ prj = path.with_suffix(".prj")
58
+ if prj.is_file():
59
+ try:
60
+ return CRS.from_wkt(prj.read_text(encoding="utf-8", errors="ignore").strip())
61
+ except Exception as exc:
62
+ logger.warning("Could not parse .prj for %s: %s", path.name, exc)
63
+ try:
64
+ return CRS.from_epsg(4326)
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _world_file_georef(path: Path, width: int, height: int):
70
+ """Build (transform, crs, bounds_wgs84) from a world file sidecar, or None."""
71
+ wf = _find_world_file(path)
72
+ if wf is None:
73
+ return None
74
+ try:
75
+ from rasterio.transform import Affine, array_bounds
76
+ from rasterio.warp import transform_bounds
77
+
78
+ nums = [float(x.strip()) for x in wf.read_text().split() if x.strip()]
79
+ if len(nums) < 6:
80
+ return None
81
+ a, d, b, e, c, f = nums[:6]
82
+ # World file stores center of top-left pixel; shift to corner for GDAL/affine
83
+ gt_c = c - a / 2.0 - b / 2.0
84
+ gt_f = f - d / 2.0 - e / 2.0
85
+ transform = Affine(a, b, gt_c, d, e, gt_f)
86
+ crs = _read_prj_crs(path)
87
+ west, south, east, north = array_bounds(height, width, transform)
88
+ if crs is not None and str(crs) not in ("EPSG:4326", "OGC:CRS84"):
89
+ west, south, east, north = transform_bounds(crs, "EPSG:4326", west, south, east, north)
90
+ return transform, crs, (float(west), float(south), float(east), float(north))
91
+ except Exception as exc:
92
+ logger.warning("World-file georef failed for %s: %s", path.name, exc)
93
+ return None
94
 
95
 
96
  def _read_with_rasterio(path: Path) -> IngestResult:
 
107
  bounds = (float(w), float(s), float(e), float(n))
108
  except Exception as exc:
109
  logger.warning("Could not transform bounds to WGS84: %s", exc)
110
+ ext = path.suffix.lower()
111
  return IngestResult(
112
  width=int(src.width),
113
  height=int(src.height),
114
  has_georef=has_georef,
115
  crs=crs,
116
  bounds_wgs84=bounds,
117
+ format="geotiff" if ext in (".tif", ".tiff") else "image",
118
+ georef_source="embedded" if has_georef else "none",
119
  )
120
 
121
 
 
124
  w, h = img.size
125
  ext = path.suffix.lower()
126
  fmt = "geotiff" if ext in (".tif", ".tiff") else "image"
127
+ # Try a world-file sidecar for plain images / non-georeferenced TIFFs
128
+ wf = _world_file_georef(path, w, h)
129
+ if wf is not None:
130
+ _, crs, bounds = wf
131
+ return IngestResult(
132
+ width=w, height=h, has_georef=bounds is not None,
133
+ crs=str(crs) if crs else "", bounds_wgs84=bounds,
134
+ format=fmt, georef_source="worldfile" if bounds else "none",
135
+ )
136
  return IngestResult(
137
  width=w,
138
  height=h,
 
140
  crs="",
141
  bounds_wgs84=None,
142
  format=fmt,
143
+ georef_source="none",
144
  )
145
 
146
 
147
  def inspect_image(path: Path) -> IngestResult:
148
+ """Read dimensions + georeferencing for any raster (TIFF/PNG/JPEG).
149
+
150
+ Tries rasterio first (honors embedded CRS, world files and GDAL .aux.xml for
151
+ all formats), then falls back to Pillow + explicit world-file parsing.
152
+ """
153
+ try:
154
+ res = _read_with_rasterio(path)
155
+ # rasterio opened but found no embedded georef: try explicit world file
156
+ if not res.has_georef:
157
+ wf = _world_file_georef(path, res.width, res.height)
158
+ if wf is not None and wf[2] is not None:
159
+ _, crs, bounds = wf
160
+ res.has_georef = True
161
+ res.crs = str(crs) if crs else ""
162
+ res.bounds_wgs84 = bounds
163
+ res.georef_source = "worldfile"
164
+ return res
165
+ except ImportError:
166
+ logger.warning("rasterio not installed β€” georef metadata limited")
167
+ except Exception as exc:
168
+ logger.warning("rasterio read failed (%s), falling back to Pillow", exc)
169
  return _read_with_pillow(path)
170
 
171
 
 
226
  except Exception as exc:
227
  raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc
228
  with Image.open(path) as img:
229
+ img = ImageOps.exif_transpose(img)
230
  img = img.convert("RGB")
231
  if max(img.size) > max_side:
232
  img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
 
251
 
252
  try:
253
  with Image.open(src_path) as img:
254
+ img = ImageOps.exif_transpose(img)
255
  img = img.convert("RGB")
256
  img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
257
  dest_path.parent.mkdir(parents=True, exist_ok=True)
 
268
  width: int
269
  height: int
270
  bounds_wgs84: Optional[Tuple[float, float, float, float]]
271
+ source: str = "embedded" # embedded | worldfile
272
 
273
 
274
  def read_georef(path: Path) -> Optional[GeorefInfo]:
275
+ """Read raster affine transform + WGS84 bounds for any raster format.
276
+
277
+ Honors embedded CRS (GeoTIFF), GDAL .aux.xml sidecars, and ESRI world files
278
+ for TIFF/PNG/JPEG. Returns None when no georeferencing can be resolved.
279
+ """
280
+ width = height = 0
281
  try:
282
  import rasterio
283
  from rasterio.warp import transform_bounds
284
 
285
  with rasterio.open(path) as src:
286
+ width, height = int(src.width), int(src.height)
287
+ if src.crs is not None:
288
+ bounds = None
289
+ try:
290
+ w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
291
+ bounds = (float(w), float(s), float(e), float(n))
292
+ except Exception as exc:
293
+ logger.warning("Could not transform bounds for %s: %s", path.name, exc)
294
+ return GeorefInfo(
295
+ transform=src.transform, crs=src.crs,
296
+ width=width, height=height, bounds_wgs84=bounds, source="embedded",
297
+ )
 
 
 
298
  except ImportError:
299
  return None
300
  except Exception as exc:
301
+ logger.warning("read_georef rasterio open failed for %s: %s", path.name, exc)
302
+
303
+ # No embedded CRS β€” try an explicit world-file sidecar
304
+ if width <= 0 or height <= 0:
305
+ try:
306
+ with Image.open(path) as img:
307
+ width, height = img.size
308
+ except Exception:
309
+ return None
310
+ wf = _world_file_georef(path, width, height)
311
+ if wf is not None and wf[2] is not None:
312
+ transform, crs, bounds = wf
313
+ return GeorefInfo(
314
+ transform=transform, crs=crs,
315
+ width=width, height=height, bounds_wgs84=bounds, source="worldfile",
316
+ )
317
+ return None
318
 
319
 
320
  def pixel_to_geo_wgs84(
app/dda/tree/image_service.py CHANGED
@@ -60,6 +60,22 @@ def image_to_dict(img: ImageLibrary, node: Optional[TreeNode] = None) -> dict:
60
  }
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  async def upload_image(
64
  db: Session,
65
  node_id: int,
@@ -68,6 +84,7 @@ async def upload_image(
68
  image_type: str,
69
  capture_date: Optional[str],
70
  uploaded_by: str,
 
71
  ) -> ImageLibrary:
72
  node = get_node_or_404(db, node_id)
73
  itype = image_type.strip() or "GeoTIFF"
@@ -103,6 +120,13 @@ async def upload_image(
103
  raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
104
 
105
  meta = inspect_image(dest)
 
 
 
 
 
 
 
106
  img = ImageLibrary(
107
  node_id=node.id,
108
  image_name=dest.name,
@@ -114,8 +138,8 @@ async def upload_image(
114
  thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
115
  width=meta.width,
116
  height=meta.height,
117
- has_georef=meta.has_georef,
118
- bounds_json=bounds_to_json(meta.bounds_wgs84) or "",
119
  format=meta.format,
120
  )
121
  db.add(img)
 
60
  }
61
 
62
 
63
+ def _parse_manual_bounds(raw: Optional[str]) -> Optional[str]:
64
+ """Parse a 'west,south,east,north' WGS84 string into bounds_json, or None."""
65
+ if not raw or not raw.strip():
66
+ return None
67
+ try:
68
+ parts = [float(x.strip()) for x in raw.replace("[", "").replace("]", "").split(",")]
69
+ except ValueError:
70
+ raise HTTPException(status_code=400, detail="manual_bounds must be 'west,south,east,north'")
71
+ if len(parts) != 4:
72
+ raise HTTPException(status_code=400, detail="manual_bounds must have 4 values: west,south,east,north")
73
+ west, south, east, north = parts
74
+ if abs(west) > 180 or abs(east) > 180 or abs(south) > 90 or abs(north) > 90:
75
+ raise HTTPException(status_code=400, detail="manual_bounds out of range (lng Β±180, lat Β±90)")
76
+ return bounds_to_json((west, south, east, north))
77
+
78
+
79
  async def upload_image(
80
  db: Session,
81
  node_id: int,
 
84
  image_type: str,
85
  capture_date: Optional[str],
86
  uploaded_by: str,
87
+ manual_bounds: Optional[str] = None,
88
  ) -> ImageLibrary:
89
  node = get_node_or_404(db, node_id)
90
  itype = image_type.strip() or "GeoTIFF"
 
120
  raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
121
 
122
  meta = inspect_image(dest)
123
+ # Prefer embedded/world-file georef; fall back to user-supplied manual bounds
124
+ manual_json = _parse_manual_bounds(manual_bounds)
125
+ bounds_json = bounds_to_json(meta.bounds_wgs84) or ""
126
+ has_georef = meta.has_georef
127
+ if not bounds_json and manual_json:
128
+ bounds_json = manual_json
129
+ has_georef = True
130
  img = ImageLibrary(
131
  node_id=node.id,
132
  image_name=dest.name,
 
138
  thumb_cache_key=hashlib.sha256(rel.encode()).hexdigest()[:32],
139
  width=meta.width,
140
  height=meta.height,
141
+ has_georef=has_georef,
142
+ bounds_json=bounds_json,
143
  format=meta.format,
144
  )
145
  db.add(img)
app/dda/tree/routes.py CHANGED
@@ -157,6 +157,7 @@ async def api_upload_image(
157
  file: UploadFile = File(...),
158
  image_type: str = Form("GeoTIFF"),
159
  capture_date: str = Form(""),
 
160
  db: Session = Depends(get_db),
161
  user: User = Depends(current_dda_user),
162
  ):
@@ -167,6 +168,7 @@ async def api_upload_image(
167
  image_type=image_type,
168
  capture_date=capture_date or None,
169
  uploaded_by=user.email or str(user.id),
 
170
  )
171
  node = get_node_or_404(db, node_id)
172
  return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
 
157
  file: UploadFile = File(...),
158
  image_type: str = Form("GeoTIFF"),
159
  capture_date: str = Form(""),
160
+ manual_bounds: str = Form(""),
161
  db: Session = Depends(get_db),
162
  user: User = Depends(current_dda_user),
163
  ):
 
168
  image_type=image_type,
169
  capture_date=capture_date or None,
170
  uploaded_by=user.email or str(user.id),
171
+ manual_bounds=manual_bounds or None,
172
  )
173
  node = get_node_or_404(db, node_id)
174
  return {"status": True, "message": "Image Uploaded Successfully", "image": image_to_dict(img, node)}
app/dda/tree/sync_service.py CHANGED
@@ -105,6 +105,17 @@ def ensure_node_from_disk(db: Session, physical_path: str, *, created_by: str =
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()
@@ -129,7 +140,7 @@ def _index_image_file(db: Session, node: TreeNode, file_path: Path, rel_file: st
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,
 
105
  return node
106
 
107
 
108
+ def _image_type_for(file_path: Path) -> str:
109
+ ext = file_path.suffix.lower()
110
+ if ext in (".tif", ".tiff"):
111
+ return "GeoTIFF"
112
+ if ext in (".jpg", ".jpeg"):
113
+ return "JPEG"
114
+ if ext == ".png":
115
+ return "PNG"
116
+ return "Raster"
117
+
118
+
119
  def _index_image_file(db: Session, node: TreeNode, file_path: Path, rel_file: str) -> bool:
120
  existing = db.query(ImageLibrary).filter(ImageLibrary.file_path == rel_file).first()
121
  stat = file_path.stat()
 
140
  img = ImageLibrary(
141
  node_id=node.id,
142
  image_name=file_path.name,
143
+ image_type=_image_type_for(file_path),
144
  file_path=rel_file,
145
  uploaded_by="filesystem-sync",
146
  file_size_bytes=stat.st_size,
app/detection_engine.py CHANGED
@@ -2533,6 +2533,11 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
2533
  if min_area is None:
2534
  min_area = int(max(250, min(1000, img_area * 0.00009)))
2535
 
 
 
 
 
 
2536
  for i in range(1, num_labels):
2537
  raw_area = stats[i, cv2.CC_STAT_AREA]
2538
  if raw_area < min_area:
 
2533
  if min_area is None:
2534
  min_area = int(max(250, min(1000, img_area * 0.00009)))
2535
 
2536
+ # Poor registration β†’ residual misalignment shows up as many small false
2537
+ # positives. Raise the size floor to keep only confident, larger changes.
2538
+ if not registration_ok:
2539
+ min_area = int(min_area * 1.4)
2540
+
2541
  for i in range(1, num_labels):
2542
  raw_area = stats[i, cv2.CC_STAT_AREA]
2543
  if raw_area < min_area:
app/main.py CHANGED
@@ -13,7 +13,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
  from fastapi.staticfiles import StaticFiles
14
  from pydantic import BaseModel
15
  from sqlalchemy.orm import Session
16
- from PIL import Image
17
 
18
  from .auth import (
19
  COOKIE_NAME,
@@ -306,7 +306,9 @@ async def detect(
306
  raise HTTPException(status_code=400, detail=f"{field_name} image is empty")
307
  if len(raw) > MAX_UPLOAD_BYTES:
308
  raise HTTPException(status_code=400, detail="Image too large (max 20 MB)")
309
- return Image.open(io.BytesIO(raw)).convert("RGB")
 
 
310
  except HTTPException:
311
  raise
312
  except Exception as e:
 
13
  from fastapi.staticfiles import StaticFiles
14
  from pydantic import BaseModel
15
  from sqlalchemy.orm import Session
16
+ from PIL import Image, ImageOps
17
 
18
  from .auth import (
19
  COOKIE_NAME,
 
306
  raise HTTPException(status_code=400, detail=f"{field_name} image is empty")
307
  if len(raw) > MAX_UPLOAD_BYTES:
308
  raise HTTPException(status_code=400, detail="Image too large (max 20 MB)")
309
+ img = Image.open(io.BytesIO(raw))
310
+ img = ImageOps.exif_transpose(img)
311
+ return img.convert("RGB")
312
  except HTTPException:
313
  raise
314
  except Exception as e:
static/js/dda/library.js CHANGED
@@ -37,6 +37,8 @@ document.getElementById('form-tree-upload')?.addEventListener('submit', async (e
37
  form.append('file', file);
38
  form.append('image_type', document.getElementById('upload-image-type')?.value || 'GeoTIFF');
39
  form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
 
 
40
 
41
  const btn = document.getElementById('btn-tree-upload');
42
  const progWrap = document.getElementById('upload-progress');
 
37
  form.append('file', file);
38
  form.append('image_type', document.getElementById('upload-image-type')?.value || 'GeoTIFF');
39
  form.append('capture_date', document.getElementById('upload-capture-date')?.value || '');
40
+ const manualBounds = document.getElementById('upload-manual-bounds')?.value?.trim();
41
+ if (manualBounds) form.append('manual_bounds', manualBounds);
42
 
43
  const btn = document.getElementById('btn-tree-upload');
44
  const progWrap = document.getElementById('upload-progress');
static/js/dda/report_page.js CHANGED
@@ -67,6 +67,10 @@ async function loadReportPage() {
67
  tbody.innerHTML = regions.length
68
  ? regions.map((r) => {
69
  const { lat, lng } = regionLatLng(r);
 
 
 
 
70
  return `
71
  <tr>
72
  <td>${r.id ?? ''}</td>
@@ -76,9 +80,10 @@ async function loadReportPage() {
76
  <td>${(r.area ?? 0).toLocaleString()}</td>
77
  <td>${formatCoord(lat)}</td>
78
  <td>${formatCoord(lng)}</td>
 
79
  </tr>`;
80
  }).join('')
81
- : '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
82
  }
83
 
84
  const pdfBtn = document.getElementById('report-pdf-btn');
 
67
  tbody.innerHTML = regions.length
68
  ? regions.map((r) => {
69
  const { lat, lng } = regionLatLng(r);
70
+ const hasCoords = Number.isFinite(Number(lat)) && Number.isFinite(Number(lng));
71
+ const mapsUrl = hasCoords
72
+ ? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
73
+ : null;
74
  return `
75
  <tr>
76
  <td>${r.id ?? ''}</td>
 
80
  <td>${(r.area ?? 0).toLocaleString()}</td>
81
  <td>${formatCoord(lat)}</td>
82
  <td>${formatCoord(lng)}</td>
83
+ <td>${mapsUrl ? `<a class="btn btn-secondary btn-sm" href="${mapsUrl}" target="_blank" rel="noopener">Map</a>` : 'β€”'}</td>
84
  </tr>`;
85
  }).join('')
86
+ : '<tr><td colspan="8" class="dim">No regions detected.</td></tr>';
87
  }
88
 
89
  const pdfBtn = document.getElementById('report-pdf-btn');
static/js/dda/result.js CHANGED
@@ -183,7 +183,12 @@ function showDdaResult(data) {
183
  tr.dataset.regionId = r.id;
184
  const subType = r.subType || 'β€”';
185
  const ddaType = r.ddaChangeType || 'β€”';
186
- const latLng = r.latLng ? `${r.latLng.lat}, ${r.latLng.lng}` : 'β€”';
 
 
 
 
 
187
  const severity = (r.severity || 'minor').toLowerCase();
188
  const stories = r.estimatedStories != null ? r.estimatedStories : 'β€”';
189
  const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : 'β€”';
@@ -208,6 +213,7 @@ function showDdaResult(data) {
208
  <button type="button" class="btn btn-secondary btn-sm btn-review-ok" data-action="confirmed" ${locked ? 'disabled' : ''} title="Confirm">βœ“</button>
209
  <button type="button" class="btn btn-secondary btn-sm btn-review-fp" data-action="false_positive" ${locked ? 'disabled' : ''} title="False positive">βœ—</button>
210
  <button type="button" class="btn btn-secondary btn-sm btn-review-locate" title="Locate">β—Ž</button>
 
211
  </td>
212
  `;
213
  return tr;
 
183
  tr.dataset.regionId = r.id;
184
  const subType = r.subType || 'β€”';
185
  const ddaType = r.ddaChangeType || 'β€”';
186
+ const mapsUrl = r.latLng
187
+ ? `https://www.google.com/maps/search/?api=1&query=${r.latLng.lat},${r.latLng.lng}`
188
+ : null;
189
+ const latLng = r.latLng
190
+ ? `<a href="${mapsUrl}" target="_blank" rel="noopener" title="Open in Google Maps">${r.latLng.lat}, ${r.latLng.lng}</a>`
191
+ : 'β€”';
192
  const severity = (r.severity || 'minor').toLowerCase();
193
  const stories = r.estimatedStories != null ? r.estimatedStories : 'β€”';
194
  const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : 'β€”';
 
213
  <button type="button" class="btn btn-secondary btn-sm btn-review-ok" data-action="confirmed" ${locked ? 'disabled' : ''} title="Confirm">βœ“</button>
214
  <button type="button" class="btn btn-secondary btn-sm btn-review-fp" data-action="false_positive" ${locked ? 'disabled' : ''} title="False positive">βœ—</button>
215
  <button type="button" class="btn btn-secondary btn-sm btn-review-locate" title="Locate">β—Ž</button>
216
+ ${mapsUrl ? `<a class="btn btn-secondary btn-sm" href="${mapsUrl}" target="_blank" rel="noopener" title="Open in Google Maps">Map</a>` : ''}
217
  </td>
218
  `;
219
  return tr;
templates/index_dda.html CHANGED
@@ -72,6 +72,7 @@
72
  <select id="upload-image-type">
73
  <option>GeoTIFF</option><option>Satellite</option><option>Drone</option>
74
  <option>Orthomosaic</option><option>DEM</option><option>Raster</option>
 
75
  </select>
76
  </div>
77
  <div class="form-group">
@@ -82,6 +83,10 @@
82
  <label for="upload-file">File</label>
83
  <input type="file" id="upload-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
84
  </div>
 
 
 
 
85
  </div>
86
  <div id="upload-progress" class="dda-upload-progress hidden">
87
  <div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
@@ -319,8 +324,8 @@
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>
 
72
  <select id="upload-image-type">
73
  <option>GeoTIFF</option><option>Satellite</option><option>Drone</option>
74
  <option>Orthomosaic</option><option>DEM</option><option>Raster</option>
75
+ <option>PNG</option><option>JPEG</option>
76
  </select>
77
  </div>
78
  <div class="form-group">
 
83
  <label for="upload-file">File</label>
84
  <input type="file" id="upload-file" accept=".tif,.tiff,.png,.jpg,.jpeg" required />
85
  </div>
86
+ <div class="form-group">
87
+ <label for="upload-manual-bounds">Geo bounds (W,S,E,N)</label>
88
+ <input type="text" id="upload-manual-bounds" placeholder="optional, e.g. 77.5,12.9,77.6,13.0" />
89
+ </div>
90
  </div>
91
  <div id="upload-progress" class="dda-upload-progress hidden">
92
  <div class="dda-progress-bar"><div id="upload-progress-fill" class="dda-progress-fill"></div></div>
 
324
 
325
  <script src="/static/js/dda/app.js?v=15"></script>
326
  <script src="/static/js/dda/tree.js?v=2"></script>
327
+ <script src="/static/js/dda/library.js?v=9"></script>
328
+ <script src="/static/js/dda/result.js?v=7"></script>
329
  <script src="/static/js/dda/compare.js?v=11"></script>
330
  <script src="/static/js/dda/reports.js?v=4"></script>
331
  <script src="/static/js/dda/notifications.js?v=1"></script>
templates/report_dda.html CHANGED
@@ -45,6 +45,7 @@
45
  <th>Area (px)</th>
46
  <th>Lat</th>
47
  <th>Lng</th>
 
48
  </tr>
49
  </thead>
50
  <tbody id="report-regions-body"></tbody>
@@ -62,6 +63,6 @@
62
  </div>
63
  </div>
64
  <script src="/static/js/dda/app.js?v=11"></script>
65
- <script src="/static/js/dda/report_page.js?v=2"></script>
66
  </body>
67
  </html>
 
45
  <th>Area (px)</th>
46
  <th>Lat</th>
47
  <th>Lng</th>
48
+ <th>Map</th>
49
  </tr>
50
  </thead>
51
  <tbody id="report-regions-body"></tbody>
 
63
  </div>
64
  </div>
65
  <script src="/static/js/dda/app.js?v=11"></script>
66
+ <script src="/static/js/dda/report_page.js?v=3"></script>
67
  </body>
68
  </html>