Spaces:
Sleeping
Sleeping
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 +14 -0
- app/dda/geo_regions.py +27 -5
- app/dda/geotiff_io.py +142 -31
- app/dda/tree/image_service.py +26 -2
- app/dda/tree/routes.py +2 -0
- app/dda/tree/sync_service.py +12 -1
- app/detection_engine.py +5 -0
- app/main.py +4 -2
- static/js/dda/library.js +2 -0
- static/js/dda/report_page.js +6 -1
- static/js/dda/result.js +7 -1
- templates/index_dda.html +7 -2
- templates/report_dda.html +2 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
if not bounds:
|
| 83 |
bounds = bounds_from_image_path(base_file)
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
if
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
try:
|
| 183 |
import rasterio
|
| 184 |
from rasterio.warp import transform_bounds
|
| 185 |
|
| 186 |
with rasterio.open(path) as src:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 118 |
-
bounds_json=
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
| 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="
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 323 |
-
<script src="/static/js/dda/result.js?v=
|
| 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=
|
| 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>
|