Spaces:
Sleeping
Sleeping
| """Background detection job runner (FR-04 async pipeline).""" | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import threading | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Dict, Optional | |
| from PIL import Image | |
| from sqlalchemy.orm import Session | |
| from ..auth import get_or_create_guest_user | |
| from ..database import SessionLocal | |
| from ..models import DetectionRun | |
| from .config import get_detection_max_side | |
| from .detect_service import run_detection_and_save | |
| from .geotiff_io import load_rgb_pil | |
| from .job_progress import update_job_progress | |
| from .local_routes import safe_resolve | |
| from .models import DetectionJob | |
| logger = logging.getLogger(__name__) | |
| _runner_lock = threading.Lock() | |
| _active_job_id: Optional[int] = None | |
| def _utcnow(): | |
| return datetime.now(timezone.utc) | |
| def _load_pair(base_path: str, comparison_path: str) -> tuple[Image.Image, Image.Image, Path]: | |
| from ..detection_config import get_load_max_side | |
| base_file = safe_resolve(base_path) | |
| comp_file = safe_resolve(comparison_path) | |
| max_side = get_load_max_side() | |
| before_pil = load_rgb_pil(base_file, max_side=max_side) | |
| after_pil = load_rgb_pil(comp_file, max_side=max_side) | |
| if before_pil.size != after_pil.size: | |
| after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS) | |
| return before_pil, after_pil, base_file | |
| def _parse_params(job: DetectionJob) -> Dict[str, Any]: | |
| try: | |
| return json.loads(job.params_json or "{}") | |
| except json.JSONDecodeError: | |
| return {} | |
| def _run_job_sync(job_id: int) -> None: | |
| global _active_job_id | |
| db = SessionLocal() | |
| try: | |
| job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first() | |
| if not job or job.status not in ("queued", "running"): | |
| return | |
| job.status = "running" | |
| job.started_at = _utcnow() | |
| job.error_message = "" | |
| db.commit() | |
| update_job_progress(job_id, 5, "Starting job") | |
| params = _parse_params(job) | |
| base_path = params.get("base_path", "") | |
| comparison_path = params.get("comparison_path", "") | |
| if not base_path or not comparison_path: | |
| raise ValueError("Job missing base_path or comparison_path in params_json") | |
| update_job_progress(job_id, 8, "Loading images") | |
| before_pil, after_pil, base_file = _load_pair(base_path, comparison_path) | |
| comp_file = safe_resolve(comparison_path) | |
| update_job_progress(job_id, 12, "Images loaded") | |
| title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}" | |
| result = run_detection_and_save( | |
| db, | |
| before_pil, | |
| after_pil, | |
| method=job.method or params.get("method", "AI-Based Deep Learning"), | |
| title=title, | |
| zone=params.get("zone", ""), | |
| village=params.get("village", ""), | |
| enable_registration=bool(params.get("enable_registration", True)), | |
| enable_normalization=bool(params.get("enable_normalization", True)), | |
| detection_sensitivity=float(params.get("detection_sensitivity", 0.45)), | |
| min_region_area=params.get("min_region_area"), | |
| notify_email=job.notify_email or params.get("notify_email"), | |
| max_size=get_detection_max_side(), | |
| geo_bounds_path=base_file, | |
| comparison_file=comp_file, | |
| base_path=base_path, | |
| user_id=job.created_by, | |
| job_id=job_id, | |
| ) | |
| update_job_progress(job_id, 100, "Complete") | |
| job.status = "completed" | |
| job.run_id = result["id"] | |
| job.completed_at = _utcnow() | |
| db.commit() | |
| logger.info("Detection job %d completed → run %s", job_id, result["id"]) | |
| except Exception as exc: | |
| logger.exception("Detection job %d failed", job_id) | |
| try: | |
| job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first() | |
| if job: | |
| job.status = "failed" | |
| job.error_message = str(exc)[:2000] | |
| job.completed_at = _utcnow() | |
| db.commit() | |
| except Exception: | |
| db.rollback() | |
| finally: | |
| with _runner_lock: | |
| if _active_job_id == job_id: | |
| _active_job_id = None | |
| db.close() | |
| def _job_worker(job_id: int) -> None: | |
| global _active_job_id | |
| with _runner_lock: | |
| _active_job_id = job_id | |
| try: | |
| _run_job_sync(job_id) | |
| finally: | |
| with _runner_lock: | |
| if _active_job_id == job_id: | |
| _active_job_id = None | |
| def enqueue_detection_job(job_id: int) -> bool: | |
| """Start job in a background thread. Returns False if another job is running.""" | |
| global _active_job_id | |
| with _runner_lock: | |
| if _active_job_id is not None: | |
| return False | |
| _active_job_id = job_id | |
| thread = threading.Thread(target=_job_worker, args=(job_id,), daemon=True, name=f"dda-job-{job_id}") | |
| thread.start() | |
| return True | |
| def is_job_runner_busy() -> bool: | |
| with _runner_lock: | |
| return _active_job_id is not None | |
| def reconcile_stale_jobs(db: Session) -> int: | |
| """Mark orphaned running jobs failed after server restart; re-queue oldest queued job.""" | |
| if is_job_runner_busy(): | |
| return 0 | |
| fixed = 0 | |
| running = db.query(DetectionJob).filter(DetectionJob.status == "running").all() | |
| for job in running: | |
| job.status = "failed" | |
| job.error_message = "Job interrupted (server restarted). Please run detection again." | |
| job.completed_at = _utcnow() | |
| fixed += 1 | |
| if fixed: | |
| db.commit() | |
| logger.info("Reconciled %d stale running job(s)", fixed) | |
| if not is_job_runner_busy(): | |
| next_queued = ( | |
| db.query(DetectionJob) | |
| .filter(DetectionJob.status == "queued") | |
| .order_by(DetectionJob.created_at.asc()) | |
| .first() | |
| ) | |
| if next_queued: | |
| enqueue_detection_job(next_queued.id) | |
| return fixed | |
| def create_local_folder_job( | |
| db: Session, | |
| *, | |
| base_path: str, | |
| comparison_path: str, | |
| method: str = "AI-Based Deep Learning", | |
| title: str = "", | |
| zone: str = "", | |
| village: str = "", | |
| enable_registration: bool = True, | |
| enable_normalization: bool = True, | |
| detection_sensitivity: float = 0.45, | |
| min_region_area: Optional[int] = 150, | |
| notify_email: str = "", | |
| created_by: Optional[int] = None, | |
| ) -> DetectionJob: | |
| user = get_or_create_guest_user(db) | |
| params = { | |
| "source": "local_folder", | |
| "base_path": base_path.replace("\\", "/"), | |
| "comparison_path": comparison_path.replace("\\", "/"), | |
| "method": method, | |
| "title": title, | |
| "zone": zone, | |
| "village": village, | |
| "enable_registration": enable_registration, | |
| "enable_normalization": enable_normalization, | |
| "detection_sensitivity": detection_sensitivity, | |
| "min_region_area": min_region_area, | |
| } | |
| job = DetectionJob( | |
| status="queued", | |
| base_image_id=None, | |
| comparison_image_id=None, | |
| method=method, | |
| params_json=json.dumps(params), | |
| notify_email=notify_email or "", | |
| created_by=created_by or user.id, | |
| ) | |
| db.add(job) | |
| db.commit() | |
| db.refresh(job) | |
| return job | |
| def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict: | |
| from .job_progress import get_job_progress | |
| params = _parse_params(job) | |
| progress_pct, progress_stage = get_job_progress(params, job.status) | |
| out = { | |
| "id": job.id, | |
| "status": job.status, | |
| "method": job.method, | |
| "basePath": params.get("base_path", ""), | |
| "comparisonPath": params.get("comparison_path", ""), | |
| "title": params.get("title", ""), | |
| "runId": job.run_id, | |
| "errorMessage": job.error_message or "", | |
| "notifyEmail": job.notify_email or "", | |
| "progressPct": progress_pct, | |
| "progressStage": progress_stage, | |
| "createdAt": job.created_at.isoformat() if job.created_at else None, | |
| "startedAt": job.started_at.isoformat() if job.started_at else None, | |
| "completedAt": job.completed_at.isoformat() if job.completed_at else None, | |
| } | |
| if run: | |
| out["report"] = { | |
| "id": run.id, | |
| "title": run.title, | |
| "changePercentage": run.change_percentage, | |
| "regionsCount": run.regions_count, | |
| "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None, | |
| } | |
| return out | |