Spaces:
Sleeping
Sleeping
Commit ·
79d533c
1
Parent(s): 5ae5432
Fix dev Space startup: add missing DDA modules referenced by local_routes.
Browse filesIncludes detect_service, geo_regions, geotiff load_rgb_pil, and thumb fallbacks
that were omitted from the 5 GB upload commit.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +1 -1
- app/dda/change_type_map.py +44 -0
- app/dda/detect_service.py +208 -0
- app/dda/geo_regions.py +114 -0
- app/dda/geotiff_io.py +84 -35
- app/dda/local_library.py +10 -5
Dockerfile
CHANGED
|
@@ -21,7 +21,7 @@ WORKDIR /app
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
-
ARG APP_BUILD=
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
|
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
+
ARG APP_BUILD=31
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
app/dda/change_type_map.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map internal detection_engine labels → DDA report change types (FR-04)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
DDA_NEW_CONSTRUCTION = "New Construction"
|
| 7 |
+
DDA_DEMOLITION = "Demolition"
|
| 8 |
+
DDA_EXTENSION = "Extension"
|
| 9 |
+
DDA_VEGETATION = "Vegetation Change"
|
| 10 |
+
DDA_OTHER = "Other"
|
| 11 |
+
|
| 12 |
+
_KEYWORDS = [
|
| 13 |
+
(DDA_DEMOLITION, ("demolition", "clearing", "removed", "debris")),
|
| 14 |
+
(DDA_EXTENSION, ("expansion", "widening", "extension", "renovation", "addition")),
|
| 15 |
+
(DDA_VEGETATION, ("vegetation", "tree", "forest", "green", "crop")),
|
| 16 |
+
(DDA_NEW_CONSTRUCTION, (
|
| 17 |
+
"construction", "building", "structure", "road", "pavement",
|
| 18 |
+
"temporary", "roof", "concrete", "foundation",
|
| 19 |
+
)),
|
| 20 |
+
(DDA_OTHER, ("water", "bare", "soil", "land", "unclassified")),
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def map_to_dda_change_type(internal_type: str) -> str:
|
| 25 |
+
"""Return canonical DDA change type for a detection_engine object_type string."""
|
| 26 |
+
if not internal_type:
|
| 27 |
+
return DDA_OTHER
|
| 28 |
+
lower = internal_type.lower()
|
| 29 |
+
for dda_type, keywords in _KEYWORDS:
|
| 30 |
+
if any(kw in lower for kw in keywords):
|
| 31 |
+
return dda_type
|
| 32 |
+
if "new" in lower:
|
| 33 |
+
return DDA_NEW_CONSTRUCTION
|
| 34 |
+
return DDA_OTHER
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def enrich_region_for_dda(region: dict, *, dda_change_type: Optional[str] = None) -> dict:
|
| 38 |
+
"""Add DDA report fields to a serialized region dict."""
|
| 39 |
+
internal = region.get("objectType") or region.get("object_type") or ""
|
| 40 |
+
out = dict(region)
|
| 41 |
+
out["ddaChangeType"] = dda_change_type or map_to_dda_change_type(internal)
|
| 42 |
+
out["internalObjectType"] = internal
|
| 43 |
+
out["reviewStatus"] = region.get("reviewStatus", "pending")
|
| 44 |
+
return out
|
app/dda/detect_service.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run change detection and persist results (shared by upload + library paths)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import timezone
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from PIL import Image
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
|
| 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 |
+
|
| 22 |
+
OVERLAYS_DIR = DATA_DIR / "overlays"
|
| 23 |
+
THUMB_MAX_SIZE = 200
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _serialize_regions(change_regions) -> list:
|
| 27 |
+
return [
|
| 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]),
|
| 35 |
+
"w": int(r["bbox"][2]),
|
| 36 |
+
"h": int(r["bbox"][3]),
|
| 37 |
+
},
|
| 38 |
+
"objectType": str(r["object_type"]),
|
| 39 |
+
"confidence": float(r["confidence"]),
|
| 40 |
+
"severity": r.get("severity", "minor"),
|
| 41 |
+
"subType": r.get("sub_type"),
|
| 42 |
+
"subTypeConfidence": float(r["sub_type_confidence"])
|
| 43 |
+
if r.get("sub_type_confidence") is not None
|
| 44 |
+
else None,
|
| 45 |
+
"estimatedStories": r.get("estimated_stories"),
|
| 46 |
+
"estimatedHeightM": float(r["estimated_height_m"])
|
| 47 |
+
if r.get("estimated_height_m") is not None
|
| 48 |
+
else None,
|
| 49 |
+
"constructionStage": r.get("construction_stage"),
|
| 50 |
+
}
|
| 51 |
+
for r in change_regions
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _isoformat_ist(dt):
|
| 56 |
+
from datetime import timedelta
|
| 57 |
+
if dt is None:
|
| 58 |
+
return None
|
| 59 |
+
_IST = timezone(timedelta(hours=5, minutes=30))
|
| 60 |
+
if dt.tzinfo is None:
|
| 61 |
+
from datetime import timezone as tz
|
| 62 |
+
dt = dt.replace(tzinfo=tz.utc)
|
| 63 |
+
return dt.astimezone(_IST).isoformat()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run_detection_and_save(
|
| 67 |
+
db: Session,
|
| 68 |
+
before_pil: Image.Image,
|
| 69 |
+
after_pil: Image.Image,
|
| 70 |
+
*,
|
| 71 |
+
method: str = "AI-Based Deep Learning",
|
| 72 |
+
title: str = "Untitled run",
|
| 73 |
+
zone: str = "",
|
| 74 |
+
village: str = "",
|
| 75 |
+
enable_registration: bool = True,
|
| 76 |
+
enable_normalization: bool = True,
|
| 77 |
+
detection_sensitivity: float = 0.5,
|
| 78 |
+
min_region_area: Optional[int] = None,
|
| 79 |
+
notify_email: Optional[str] = None,
|
| 80 |
+
max_size: Optional[int] = None,
|
| 81 |
+
geo_bounds_path: Optional[Path] = None,
|
| 82 |
+
) -> dict:
|
| 83 |
+
from ..detection_engine import run_detection
|
| 84 |
+
|
| 85 |
+
user = get_or_create_guest_user(db)
|
| 86 |
+
detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
|
| 87 |
+
if min_region_area is not None:
|
| 88 |
+
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 89 |
+
|
| 90 |
+
change_mask, result_image, stats, change_regions = run_detection(
|
| 91 |
+
before_pil,
|
| 92 |
+
after_pil,
|
| 93 |
+
method=method,
|
| 94 |
+
enable_registration=enable_registration,
|
| 95 |
+
enable_normalization=enable_normalization,
|
| 96 |
+
detection_sensitivity=detection_sensitivity,
|
| 97 |
+
min_region_area=min_region_area,
|
| 98 |
+
max_size=max_size,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Save before image at detection resolution (matches overlay coordinates for slider)
|
| 102 |
+
from ..detection_engine import preprocess_image, get_detection_max_size
|
| 103 |
+
|
| 104 |
+
before_for_slider = Image.fromarray(
|
| 105 |
+
preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
base_name = f"{user.id}_{uuid.uuid4().hex}"
|
| 109 |
+
overlay_filename = base_name + ".png"
|
| 110 |
+
overlay_path = OVERLAYS_DIR / overlay_filename
|
| 111 |
+
overlay_path.parent.mkdir(parents=True, exist_ok=True)
|
| 112 |
+
Image.fromarray(result_image).save(overlay_path)
|
| 113 |
+
relative_overlay = f"overlays/{overlay_filename}"
|
| 114 |
+
|
| 115 |
+
relative_before_full = ""
|
| 116 |
+
relative_before_thumb = ""
|
| 117 |
+
relative_after_thumb = ""
|
| 118 |
+
try:
|
| 119 |
+
before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
|
| 120 |
+
before_for_slider.save(before_full_file)
|
| 121 |
+
relative_before_full = f"overlays/{base_name}_before.png"
|
| 122 |
+
before_thumb_pil = before_pil.copy()
|
| 123 |
+
before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 124 |
+
before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
|
| 125 |
+
after_thumb_pil = after_pil.copy()
|
| 126 |
+
after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 127 |
+
after_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_after_thumb.png")
|
| 128 |
+
relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
|
| 129 |
+
relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
|
| 130 |
+
except Exception as exc:
|
| 131 |
+
logger.warning("Failed to save thumbnails: %s", exc)
|
| 132 |
+
|
| 133 |
+
regions_serializable = _serialize_regions(change_regions)
|
| 134 |
+
img_w, img_h = before_for_slider.size
|
| 135 |
+
bounds = bounds_from_image_path(geo_bounds_path) if geo_bounds_path else None
|
| 136 |
+
regions_serializable = enrich_regions_geo(
|
| 137 |
+
regions_serializable,
|
| 138 |
+
img_width=img_w,
|
| 139 |
+
img_height=img_h,
|
| 140 |
+
bounds=bounds,
|
| 141 |
+
)
|
| 142 |
+
total_px = int(stats["total_pixels"])
|
| 143 |
+
changed_px = int(stats["changed_pixels"])
|
| 144 |
+
change_pct = float(stats["change_percentage"])
|
| 145 |
+
|
| 146 |
+
run = DetectionRun(
|
| 147 |
+
user_id=user.id,
|
| 148 |
+
title=title,
|
| 149 |
+
method=method,
|
| 150 |
+
zone=zone,
|
| 151 |
+
village=village,
|
| 152 |
+
total_pixels=total_px,
|
| 153 |
+
changed_pixels=changed_px,
|
| 154 |
+
change_percentage=change_pct,
|
| 155 |
+
regions_count=len(change_regions),
|
| 156 |
+
overlay_path=relative_overlay,
|
| 157 |
+
before_full_path=relative_before_full,
|
| 158 |
+
before_thumb_path=relative_before_thumb,
|
| 159 |
+
after_thumb_path=relative_after_thumb,
|
| 160 |
+
regions_json=json.dumps(regions_serializable),
|
| 161 |
+
)
|
| 162 |
+
db.add(run)
|
| 163 |
+
db.commit()
|
| 164 |
+
db.refresh(run)
|
| 165 |
+
|
| 166 |
+
overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
|
| 167 |
+
notification_sent = False
|
| 168 |
+
notification_error = None
|
| 169 |
+
if notify_email and notify_email.strip():
|
| 170 |
+
notification_sent, notification_error = send_notification(
|
| 171 |
+
recipient=notify_email.strip(),
|
| 172 |
+
title=title,
|
| 173 |
+
method=method,
|
| 174 |
+
zone=zone,
|
| 175 |
+
village=village,
|
| 176 |
+
change_pct=change_pct,
|
| 177 |
+
changed_px=changed_px,
|
| 178 |
+
total_px=total_px,
|
| 179 |
+
regions=regions_serializable,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"id": run.id,
|
| 184 |
+
"title": run.title,
|
| 185 |
+
"method": run.method,
|
| 186 |
+
"zone": run.zone or "",
|
| 187 |
+
"village": run.village or "",
|
| 188 |
+
"statistics": {
|
| 189 |
+
"totalPixels": total_px,
|
| 190 |
+
"changedPixels": changed_px,
|
| 191 |
+
"unchangedPixels": int(stats["unchanged_pixels"]),
|
| 192 |
+
"changePercentage": change_pct,
|
| 193 |
+
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 194 |
+
"params": stats.get("params", {}),
|
| 195 |
+
"alignmentWarning": stats.get("alignment_warning"),
|
| 196 |
+
"registrationOk": stats.get("params", {}).get("registration_ok"),
|
| 197 |
+
},
|
| 198 |
+
"regions": regions_serializable,
|
| 199 |
+
"overlayBase64Png": overlay_b64,
|
| 200 |
+
"overlayUrl": f"/api/overlay/{relative_overlay}",
|
| 201 |
+
"beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
|
| 202 |
+
"beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
|
| 203 |
+
"afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
|
| 204 |
+
"notificationSent": notification_sent,
|
| 205 |
+
"notificationError": notification_error,
|
| 206 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 207 |
+
"detectionMaxSide": max_size,
|
| 208 |
+
}
|
app/dda/geo_regions.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pixel coordinates → WGS84 lat/lng for geo-referenced images (FR-04, FR-06)."""
|
| 2 |
+
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
|
| 20 |
+
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
|
| 21 |
+
return tuple(float(x) for x in bounds)
|
| 22 |
+
if isinstance(bounds, dict):
|
| 23 |
+
try:
|
| 24 |
+
return (
|
| 25 |
+
float(bounds["west"]),
|
| 26 |
+
float(bounds["south"]),
|
| 27 |
+
float(bounds["east"]),
|
| 28 |
+
float(bounds["north"]),
|
| 29 |
+
)
|
| 30 |
+
except (KeyError, TypeError, ValueError):
|
| 31 |
+
return None
|
| 32 |
+
if isinstance(bounds, str) and bounds.strip():
|
| 33 |
+
try:
|
| 34 |
+
data = json.loads(bounds)
|
| 35 |
+
return parse_bounds(data)
|
| 36 |
+
except json.JSONDecodeError:
|
| 37 |
+
parts = [float(x.strip()) for x in bounds.replace("[", "").replace("]", "").split(",")]
|
| 38 |
+
if len(parts) == 4:
|
| 39 |
+
return tuple(parts)
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
|
| 44 |
+
try:
|
| 45 |
+
meta = inspect_image(path)
|
| 46 |
+
return meta.bounds_wgs84
|
| 47 |
+
except Exception as exc:
|
| 48 |
+
logger.warning("Could not read bounds for %s: %s", path.name, exc)
|
| 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 |
+
|
| 67 |
+
def bbox_area_sq_m(
|
| 68 |
+
bbox: Dict[str, int],
|
| 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 |
+
|
| 89 |
+
def enrich_regions_geo(
|
| 90 |
+
regions: List[dict],
|
| 91 |
+
*,
|
| 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)
|
| 114 |
+
return out
|
app/dda/geotiff_io.py
CHANGED
|
@@ -9,6 +9,8 @@ from typing import Optional, Tuple
|
|
| 9 |
|
| 10 |
from PIL import Image
|
| 11 |
|
|
|
|
|
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
|
|
@@ -73,47 +75,94 @@ def inspect_image(path: Path) -> IngestResult:
|
|
| 73 |
return _read_with_pillow(path)
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
|
| 77 |
"""Create RGB thumbnail/preview — uses decimated read for large GeoTIFFs."""
|
| 78 |
ext = src_path.suffix.lower()
|
| 79 |
if ext in (".tif", ".tiff"):
|
| 80 |
try:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
scale = min(1.0, max_side / max(src.width, src.height, 1))
|
| 88 |
-
out_h = max(1, int(src.height * scale))
|
| 89 |
-
out_w = max(1, int(src.width * scale))
|
| 90 |
-
data = src.read(
|
| 91 |
-
indexes=list(range(1, count + 1)),
|
| 92 |
-
out_shape=(count, out_h, out_w),
|
| 93 |
-
resampling=Resampling.bilinear,
|
| 94 |
-
)
|
| 95 |
-
if count == 1:
|
| 96 |
-
rgb = np.stack([data[0], data[0], data[0]])
|
| 97 |
-
else:
|
| 98 |
-
rgb = data[:3]
|
| 99 |
-
rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
|
| 100 |
-
if rgb.max() > 255 or rgb.min() < 0:
|
| 101 |
-
lo, hi = np.percentile(rgb, (2, 98))
|
| 102 |
-
rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
|
| 103 |
-
img = Image.fromarray(rgb.astype("uint8"), mode="RGB")
|
| 104 |
-
if max(img.size) > max_side:
|
| 105 |
-
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 106 |
-
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 107 |
-
img.save(dest_path, format="PNG")
|
| 108 |
-
return
|
| 109 |
except Exception as exc:
|
| 110 |
-
logger.warning("GeoTIFF preview
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
|
|
|
|
| 9 |
|
| 10 |
from PIL import Image
|
| 11 |
|
| 12 |
+
from .config import get_detection_max_side
|
| 13 |
+
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
|
|
|
|
| 75 |
return _read_with_pillow(path)
|
| 76 |
|
| 77 |
|
| 78 |
+
def write_placeholder_png(dest_path: Path, label: str = "Image", max_side: int = 256) -> None:
|
| 79 |
+
"""Fallback thumb when GeoTIFF is too large or rasterio is unavailable."""
|
| 80 |
+
from PIL import ImageDraw
|
| 81 |
+
|
| 82 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
img = Image.new("RGB", (max_side, max_side), color=(32, 40, 52))
|
| 84 |
+
draw = ImageDraw.Draw(img)
|
| 85 |
+
lines = [label[:28], "preview N/A"]
|
| 86 |
+
y = max_side // 2 - 20
|
| 87 |
+
for line in lines:
|
| 88 |
+
draw.text((12, y), line, fill=(100, 200, 170))
|
| 89 |
+
y += 18
|
| 90 |
+
img.save(dest_path, format="PNG")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _rasterio_read_rgb(path: Path, max_side: int):
|
| 94 |
+
import numpy as np
|
| 95 |
+
import rasterio
|
| 96 |
+
from rasterio.enums import Resampling
|
| 97 |
+
|
| 98 |
+
with rasterio.open(path) as src:
|
| 99 |
+
count = min(3, src.count)
|
| 100 |
+
scale = min(1.0, max_side / max(src.width, src.height, 1))
|
| 101 |
+
out_h = max(1, int(src.height * scale))
|
| 102 |
+
out_w = max(1, int(src.width * scale))
|
| 103 |
+
data = src.read(
|
| 104 |
+
indexes=list(range(1, count + 1)),
|
| 105 |
+
out_shape=(count, out_h, out_w),
|
| 106 |
+
resampling=Resampling.bilinear,
|
| 107 |
+
)
|
| 108 |
+
if count == 1:
|
| 109 |
+
rgb = np.stack([data[0], data[0], data[0]])
|
| 110 |
+
else:
|
| 111 |
+
rgb = data[:3]
|
| 112 |
+
rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
|
| 113 |
+
if rgb.max() > 255 or rgb.min() < 0:
|
| 114 |
+
lo, hi = np.percentile(rgb, (2, 98))
|
| 115 |
+
rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
|
| 116 |
+
return Image.fromarray(rgb.astype("uint8"), mode="RGB")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image:
|
| 120 |
+
"""Load image as RGB PIL, downscaling large GeoTIFFs via rasterio."""
|
| 121 |
+
if max_side is None:
|
| 122 |
+
max_side = get_detection_max_side()
|
| 123 |
+
ext = path.suffix.lower()
|
| 124 |
+
if ext in (".tif", ".tiff"):
|
| 125 |
+
try:
|
| 126 |
+
img = _rasterio_read_rgb(path, max_side)
|
| 127 |
+
return img.copy()
|
| 128 |
+
except ImportError as exc:
|
| 129 |
+
raise RuntimeError(
|
| 130 |
+
"GeoTIFF support requires rasterio. Install with: pip install rasterio"
|
| 131 |
+
) from exc
|
| 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)
|
| 138 |
+
return img.copy()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
|
| 142 |
"""Create RGB thumbnail/preview — uses decimated read for large GeoTIFFs."""
|
| 143 |
ext = src_path.suffix.lower()
|
| 144 |
if ext in (".tif", ".tiff"):
|
| 145 |
try:
|
| 146 |
+
img = _rasterio_read_rgb(src_path, max_side)
|
| 147 |
+
if max(img.size) > max_side:
|
| 148 |
+
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 149 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 150 |
+
img.save(dest_path, format="PNG")
|
| 151 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
except Exception as exc:
|
| 153 |
+
logger.warning("GeoTIFF preview failed for %s: %s", src_path.name, exc)
|
| 154 |
+
write_placeholder_png(dest_path, src_path.name, max_side)
|
| 155 |
+
return
|
| 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)
|
| 162 |
+
img.save(dest_path, format="PNG")
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
logger.warning("Preview failed for %s: %s", src_path.name, exc)
|
| 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:
|
app/dda/local_library.py
CHANGED
|
@@ -23,7 +23,7 @@ from urllib.parse import quote
|
|
| 23 |
from fastapi import HTTPException
|
| 24 |
|
| 25 |
from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
|
| 26 |
-
from .geotiff_io import inspect_image, raster_to_preview_png
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
@@ -157,11 +157,16 @@ def thumb_cache_path(relative_path: str) -> Path:
|
|
| 157 |
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 158 |
full = safe_resolve(relative_path)
|
| 159 |
cache = thumb_cache_path(relative_path)
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
return cache
|
| 162 |
-
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 163 |
-
raster_to_preview_png(full, cache, max_side=max_side)
|
| 164 |
-
return cache
|
| 165 |
|
| 166 |
|
| 167 |
def ensure_root() -> None:
|
|
|
|
| 23 |
from fastapi import HTTPException
|
| 24 |
|
| 25 |
from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
|
| 26 |
+
from .geotiff_io import inspect_image, raster_to_preview_png, write_placeholder_png
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
| 157 |
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 158 |
full = safe_resolve(relative_path)
|
| 159 |
cache = thumb_cache_path(relative_path)
|
| 160 |
+
try:
|
| 161 |
+
if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
|
| 162 |
+
return cache
|
| 163 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
raster_to_preview_png(full, cache, max_side=max_side)
|
| 165 |
+
return cache
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
logger.warning("Thumb build failed for %s: %s", relative_path, exc)
|
| 168 |
+
write_placeholder_png(cache, Path(relative_path).name, max_side)
|
| 169 |
return cache
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
def ensure_root() -> None:
|