"""Run change detection and persist results (shared by upload + library paths).""" from __future__ import annotations import base64 import json import logging import uuid from datetime import timezone from pathlib import Path from typing import Optional from PIL import Image from sqlalchemy.orm import Session from ..auth import get_or_create_guest_user from ..database import DATA_DIR from ..models import DetectionRun from .geo_regions import enrich_regions_geo, resolve_geo_context logger = logging.getLogger(__name__) OVERLAYS_DIR = DATA_DIR / "overlays" THUMB_MAX_SIZE = 200 def _serialize_regions(change_regions) -> list: return [ { "id": int(r["id"]), "area": int(r["area"]), "center": {"x": round(float(r["center"][0])), "y": round(float(r["center"][1]))}, "bbox": { "x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3]), }, "objectType": str(r["object_type"]), "confidence": float(r["confidence"]), "severity": r.get("severity", "minor"), "subType": r.get("sub_type"), "subTypeConfidence": float(r["sub_type_confidence"]) if r.get("sub_type_confidence") is not None else None, "estimatedStories": r.get("estimated_stories"), "estimatedHeightM": float(r["estimated_height_m"]) if r.get("estimated_height_m") is not None else None, "constructionStage": r.get("construction_stage"), } for r in change_regions ] def _isoformat_ist(dt): from datetime import timedelta if dt is None: return None _IST = timezone(timedelta(hours=5, minutes=30)) if dt.tzinfo is None: from datetime import timezone as tz dt = dt.replace(tzinfo=tz.utc) return dt.astimezone(_IST).isoformat() def run_detection_and_save( db: Session, before_pil: Image.Image, after_pil: Image.Image, *, method: str = "AI-Based Deep Learning", title: str = "Untitled run", zone: str = "", village: str = "", enable_registration: bool = True, enable_normalization: bool = True, detection_sensitivity: float = 0.5, min_region_area: Optional[int] = None, notify_email: Optional[str] = None, max_size: Optional[int] = None, geo_bounds_path: Optional[Path] = None, comparison_file: Optional[Path] = None, base_path: str = "", user_id: Optional[int] = None, job_id: Optional[int] = None, ) -> dict: from ..detection_engine import run_detection from .job_progress import update_job_progress def _report(pct: int, stage: str) -> None: if job_id is not None: update_job_progress(job_id, pct, stage) if user_id: from ..auth import get_user_by_id user = get_user_by_id(db, user_id) if not user: user = get_or_create_guest_user(db) else: user = get_or_create_guest_user(db) detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity))) if min_region_area is not None: min_region_area = int(max(50, min(10000, min_region_area))) def _on_engine_progress(engine_pct: int, stage: str) -> None: # Map engine 0–100% into job 15–78% job_pct = 15 + int(engine_pct * 0.63) _report(job_pct, stage) _report(15, "Running detection") def _geotiff_path(p: Optional[Path]) -> Optional[str]: if p is not None and str(p).lower().endswith((".tif", ".tiff")): return str(p) return None change_mask, result_image, stats, change_regions = run_detection( before_pil, after_pil, method=method, enable_registration=enable_registration, enable_normalization=enable_normalization, detection_sensitivity=detection_sensitivity, min_region_area=min_region_area, max_size=max_size, on_progress=_on_engine_progress, before_path=_geotiff_path(geo_bounds_path), after_path=_geotiff_path(comparison_file), ) _report(80, "Saving results") from ..detection_engine import preprocess_image, get_detection_max_size before_for_slider = Image.fromarray( preprocess_image(before_pil, max_size=max_size or get_detection_max_size()) ) base_name = f"{user.id}_{uuid.uuid4().hex}" overlay_filename = base_name + ".png" overlay_path = OVERLAYS_DIR / overlay_filename overlay_path.parent.mkdir(parents=True, exist_ok=True) Image.fromarray(result_image).save(overlay_path) relative_overlay = f"overlays/{overlay_filename}" relative_prob = "" try: from ..detection_config import get_save_prob_map score_map = stats.pop("_score_map", None) if get_save_prob_map() and score_map is not None: import numpy as _np prob_u8 = _np.clip(score_map * 255.0, 0, 255).astype("uint8") prob_img = Image.fromarray(prob_u8) prob_img.thumbnail((1024, 1024), Image.Resampling.LANCZOS) prob_file = OVERLAYS_DIR / f"{base_name}_prob.png" prob_img.save(prob_file) relative_prob = f"overlays/{base_name}_prob.png" except Exception as exc: logger.warning("Probability map export failed: %s", exc) relative_before_full = "" relative_before_thumb = "" relative_after_thumb = "" relative_after_full = "" try: after_for_slider = Image.fromarray( preprocess_image(after_pil, max_size=max_size or get_detection_max_size()) ) before_full_file = OVERLAYS_DIR / f"{base_name}_before.png" before_for_slider.save(before_full_file) relative_before_full = f"overlays/{base_name}_before.png" after_full_file = OVERLAYS_DIR / f"{base_name}_after.png" after_for_slider.save(after_full_file) relative_after_full = f"overlays/{base_name}_after.png" before_thumb_pil = before_pil.copy() before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS) before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png") after_thumb_pil = after_pil.copy() after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS) after_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_after_thumb.png") relative_before_thumb = f"overlays/{base_name}_before_thumb.png" relative_after_thumb = f"overlays/{base_name}_after_thumb.png" except Exception as exc: logger.warning("Failed to save thumbnails: %s", exc) regions_serializable = _serialize_regions(change_regions) det_w = int(stats.get("image_width") or 0) det_h = int(stats.get("image_height") or 0) if det_w <= 0 or det_h <= 0: det_w, det_h = before_for_slider.size geo_ctx = None bounds = None if geo_bounds_path: rel_path = (base_path or "").replace("\\", "/").strip().lstrip("/") if not rel_path: try: from .config import get_storage_root rel_path = geo_bounds_path.resolve().relative_to(get_storage_root().resolve()).as_posix() except Exception: rel_path = geo_bounds_path.name geo_ctx = resolve_geo_context(db, rel_path, geo_bounds_path) bounds = geo_ctx.bounds regions_serializable = enrich_regions_geo( regions_serializable, img_width=det_w, img_height=det_h, bounds=bounds, geo=geo_ctx, ) regions_with_coords = sum(1 for r in regions_serializable if r.get("latLng")) geo_debug = { "source": geo_ctx.source if geo_ctx else "none", "crs": str(geo_ctx.georef.crs) if (geo_ctx and geo_ctx.georef and geo_ctx.georef.crs) else "", "bounds": list(bounds) if bounds else None, "georefWidth": geo_ctx.georef_width if geo_ctx else 0, "georefHeight": geo_ctx.georef_height if geo_ctx else 0, "detectionWidth": det_w, "detectionHeight": det_h, "regionsWithCoords": regions_with_coords, "regionsTotal": len(regions_serializable), } logger.info("Geo debug for run: %s", geo_debug) total_px = int(stats["total_pixels"]) changed_px = int(stats["changed_pixels"]) change_pct = float(stats["change_percentage"]) run = DetectionRun( user_id=user.id, title=title, method=method, zone=zone, village=village, total_pixels=total_px, changed_pixels=changed_px, change_percentage=change_pct, regions_count=len(change_regions), overlay_path=relative_overlay, before_full_path=relative_before_full, before_thumb_path=relative_before_thumb, after_thumb_path=relative_after_thumb, after_full_path=relative_after_full, regions_json=json.dumps(regions_serializable), ) db.add(run) db.commit() db.refresh(run) _report(90, "Preparing report") overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8") notification_sent = False notification_error = None if notify_email and notify_email.strip(): _report(95, "Sending notification") from .config import IS_DDA_MODE, get_public_base_url report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else "" notification_sent, notification_error = send_notification( recipient=notify_email.strip(), title=title, method=method, zone=zone, village=village, change_pct=change_pct, changed_px=changed_px, total_px=total_px, regions=regions_serializable, report_url=report_url, ) _report(100, "Complete") return { "id": run.id, "title": run.title, "method": run.method, "zone": run.zone or "", "village": run.village or "", "statistics": { "totalPixels": total_px, "changedPixels": changed_px, "unchangedPixels": int(stats["unchanged_pixels"]), "changePercentage": change_pct, "thresholdDebug": stats.get("threshold_debug", {}), "params": stats.get("params", {}), "alignmentWarning": stats.get("alignment_warning"), "registrationOk": stats.get("params", {}).get("registration_ok"), "geo": geo_debug, "probabilityMapUrl": f"/api/overlay/{relative_prob}" if relative_prob else None, }, "regions": regions_serializable, "overlayBase64Png": overlay_b64, "overlayUrl": f"/api/overlay/{relative_overlay}", "beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None, "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None, "afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None, "afterFullUrl": f"/api/overlay/{relative_after_full}" if relative_after_full else None, "notificationSent": notification_sent, "notificationError": notification_error, "createdAt": _isoformat_ist(run.created_at), "detectionMaxSide": max_size, }