satdetect-dev / app /dda /detect_service.py
coderuday21's picture
Improve detection accuracy with full-res tiling, config flags, and evaluation.
99e1f27
Raw
History Blame Contribute Delete
11.4 kB
"""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,
}