Spaces:
Sleeping
Sleeping
Commit ·
bec7397
1
Parent(s): 669f7f8
Fix DDA dev bugs and polish from full review pass.
Browse filesJob poll/history fallback, stale job reconciliation, after_full for T2 view, lat/lng on reports/PDF, review submit in file mode, and UI hardening.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +1 -1
- app/dda/bootstrap.py +16 -2
- app/dda/dept_export.py +2 -3
- app/dda/detect_service.py +9 -0
- app/dda/geo_regions.py +14 -0
- app/dda/job_runner.py +27 -0
- app/dda/jobs_routes.py +6 -0
- app/dda/library_routes.py +1 -0
- app/dda/report_pdf.py +9 -5
- app/dda/reports_routes.py +3 -3
- app/dda/review_routes.py +1 -4
- app/main.py +12 -1
- app/models.py +1 -0
- library_sources/2026/.gitkeep +1 -0
- static/css/dda.css +14 -0
- static/js/dda/app.js +31 -6
- static/js/dda/compare.js +14 -4
- static/js/dda/report_page.js +15 -5
- static/js/dda/reports.js +1 -1
- static/js/dda/result.js +3 -3
- templates/index_dda.html +5 -5
- templates/report_dda.html +2 -2
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=36
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
app/dda/bootstrap.py
CHANGED
|
@@ -23,18 +23,32 @@ def init_dda_database():
|
|
| 23 |
ensure_local_year_folders()
|
| 24 |
try:
|
| 25 |
with engine.connect() as conn:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
try:
|
| 27 |
-
conn.execute(sa_text(
|
|
|
|
|
|
|
|
|
|
| 28 |
conn.commit()
|
| 29 |
except Exception:
|
| 30 |
conn.rollback()
|
| 31 |
except Exception as exc:
|
| 32 |
-
logger.warning("DDA
|
| 33 |
|
| 34 |
from ..database import SessionLocal
|
| 35 |
db = SessionLocal()
|
| 36 |
try:
|
| 37 |
seed_delhi_hierarchy(db)
|
|
|
|
|
|
|
| 38 |
finally:
|
| 39 |
db.close()
|
| 40 |
|
|
|
|
| 23 |
ensure_local_year_folders()
|
| 24 |
try:
|
| 25 |
with engine.connect() as conn:
|
| 26 |
+
for stmt in (
|
| 27 |
+
"ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'",
|
| 28 |
+
"ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
|
| 29 |
+
):
|
| 30 |
+
try:
|
| 31 |
+
conn.execute(sa_text(stmt))
|
| 32 |
+
conn.commit()
|
| 33 |
+
except Exception:
|
| 34 |
+
conn.rollback()
|
| 35 |
try:
|
| 36 |
+
conn.execute(sa_text(
|
| 37 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_dda_region_reviews_run_region "
|
| 38 |
+
"ON dda_region_reviews (run_id, region_id)"
|
| 39 |
+
))
|
| 40 |
conn.commit()
|
| 41 |
except Exception:
|
| 42 |
conn.rollback()
|
| 43 |
except Exception as exc:
|
| 44 |
+
logger.warning("DDA schema migration skipped: %s", exc)
|
| 45 |
|
| 46 |
from ..database import SessionLocal
|
| 47 |
db = SessionLocal()
|
| 48 |
try:
|
| 49 |
seed_delhi_hierarchy(db)
|
| 50 |
+
from .job_runner import reconcile_stale_jobs
|
| 51 |
+
reconcile_stale_jobs(db)
|
| 52 |
finally:
|
| 53 |
db.close()
|
| 54 |
|
app/dda/dept_export.py
CHANGED
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
import csv
|
| 5 |
import io
|
| 6 |
-
import json
|
| 7 |
import logging
|
| 8 |
import os
|
| 9 |
from dataclasses import dataclass
|
|
@@ -12,6 +11,7 @@ from typing import Any, Dict, List, Optional
|
|
| 12 |
import requests
|
| 13 |
|
| 14 |
from ..models import DetectionRun
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
@@ -39,8 +39,7 @@ def regions_to_csv_rows(run: DetectionRun, regions: List[dict]) -> str:
|
|
| 39 |
"latitude", "longitude", "area_sq_m", "review_status", "notes",
|
| 40 |
])
|
| 41 |
for r in regions:
|
| 42 |
-
lat =
|
| 43 |
-
lng = r.get("longitude") or (r.get("latLng") or {}).get("lng")
|
| 44 |
writer.writerow([
|
| 45 |
run.id,
|
| 46 |
run.title,
|
|
|
|
| 3 |
|
| 4 |
import csv
|
| 5 |
import io
|
|
|
|
| 6 |
import logging
|
| 7 |
import os
|
| 8 |
from dataclasses import dataclass
|
|
|
|
| 11 |
import requests
|
| 12 |
|
| 13 |
from ..models import DetectionRun
|
| 14 |
+
from .geo_regions import region_lat_lng
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
| 39 |
"latitude", "longitude", "area_sq_m", "review_status", "notes",
|
| 40 |
])
|
| 41 |
for r in regions:
|
| 42 |
+
lat, lng = region_lat_lng(r)
|
|
|
|
| 43 |
writer.writerow([
|
| 44 |
run.id,
|
| 45 |
run.title,
|
app/dda/detect_service.py
CHANGED
|
@@ -115,10 +115,17 @@ def run_detection_and_save(
|
|
| 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")
|
|
@@ -157,6 +164,7 @@ def run_detection_and_save(
|
|
| 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)
|
|
@@ -204,6 +212,7 @@ def run_detection_and_save(
|
|
| 204 |
"beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
|
| 205 |
"beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
|
| 206 |
"afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
|
|
|
|
| 207 |
"notificationSent": notification_sent,
|
| 208 |
"notificationError": notification_error,
|
| 209 |
"createdAt": _isoformat_ist(run.created_at),
|
|
|
|
| 115 |
relative_before_full = ""
|
| 116 |
relative_before_thumb = ""
|
| 117 |
relative_after_thumb = ""
|
| 118 |
+
relative_after_full = ""
|
| 119 |
try:
|
| 120 |
+
after_for_slider = Image.fromarray(
|
| 121 |
+
preprocess_image(after_pil, max_size=max_size or get_detection_max_size())
|
| 122 |
+
)
|
| 123 |
before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
|
| 124 |
before_for_slider.save(before_full_file)
|
| 125 |
relative_before_full = f"overlays/{base_name}_before.png"
|
| 126 |
+
after_full_file = OVERLAYS_DIR / f"{base_name}_after.png"
|
| 127 |
+
after_for_slider.save(after_full_file)
|
| 128 |
+
relative_after_full = f"overlays/{base_name}_after.png"
|
| 129 |
before_thumb_pil = before_pil.copy()
|
| 130 |
before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 131 |
before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
|
|
|
|
| 164 |
before_full_path=relative_before_full,
|
| 165 |
before_thumb_path=relative_before_thumb,
|
| 166 |
after_thumb_path=relative_after_thumb,
|
| 167 |
+
after_full_path=relative_after_full,
|
| 168 |
regions_json=json.dumps(regions_serializable),
|
| 169 |
)
|
| 170 |
db.add(run)
|
|
|
|
| 212 |
"beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
|
| 213 |
"beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
|
| 214 |
"afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
|
| 215 |
+
"afterFullUrl": f"/api/overlay/{relative_after_full}" if relative_after_full else None,
|
| 216 |
"notificationSent": notification_sent,
|
| 217 |
"notificationError": notification_error,
|
| 218 |
"createdAt": _isoformat_ist(run.created_at),
|
app/dda/geo_regions.py
CHANGED
|
@@ -112,3 +112,17 @@ def enrich_regions_geo(
|
|
| 112 |
enriched["latLng"] = None
|
| 113 |
out.append(enriched)
|
| 114 |
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
enriched["latLng"] = None
|
| 113 |
out.append(enriched)
|
| 114 |
return out
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
|
| 118 |
+
"""Read lat/lng from region dict (supports latLng object or flat keys)."""
|
| 119 |
+
ll = region.get("latLng") or {}
|
| 120 |
+
lat = region.get("latitude", ll.get("lat") if isinstance(ll, dict) else None)
|
| 121 |
+
lng = region.get("longitude", ll.get("lng") if isinstance(ll, dict) else None)
|
| 122 |
+
if lat is None or lng is None:
|
| 123 |
+
return None, None
|
| 124 |
+
try:
|
| 125 |
+
return float(lat), float(lng)
|
| 126 |
+
except (TypeError, ValueError):
|
| 127 |
+
return None, None
|
| 128 |
+
|
app/dda/job_runner.py
CHANGED
|
@@ -138,6 +138,33 @@ def is_job_runner_busy() -> bool:
|
|
| 138 |
return _active_job_id is not None
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def create_local_folder_job(
|
| 142 |
db: Session,
|
| 143 |
*,
|
|
|
|
| 138 |
return _active_job_id is not None
|
| 139 |
|
| 140 |
|
| 141 |
+
def reconcile_stale_jobs(db: Session) -> int:
|
| 142 |
+
"""Mark orphaned running jobs failed after server restart; re-queue oldest queued job."""
|
| 143 |
+
if is_job_runner_busy():
|
| 144 |
+
return 0
|
| 145 |
+
fixed = 0
|
| 146 |
+
running = db.query(DetectionJob).filter(DetectionJob.status == "running").all()
|
| 147 |
+
for job in running:
|
| 148 |
+
job.status = "failed"
|
| 149 |
+
job.error_message = "Job interrupted (server restarted). Please run detection again."
|
| 150 |
+
job.completed_at = _utcnow()
|
| 151 |
+
fixed += 1
|
| 152 |
+
if fixed:
|
| 153 |
+
db.commit()
|
| 154 |
+
logger.info("Reconciled %d stale running job(s)", fixed)
|
| 155 |
+
|
| 156 |
+
if not is_job_runner_busy():
|
| 157 |
+
next_queued = (
|
| 158 |
+
db.query(DetectionJob)
|
| 159 |
+
.filter(DetectionJob.status == "queued")
|
| 160 |
+
.order_by(DetectionJob.created_at.asc())
|
| 161 |
+
.first()
|
| 162 |
+
)
|
| 163 |
+
if next_queued:
|
| 164 |
+
enqueue_detection_job(next_queued.id)
|
| 165 |
+
return fixed
|
| 166 |
+
|
| 167 |
+
|
| 168 |
def create_local_folder_job(
|
| 169 |
db: Session,
|
| 170 |
*,
|
app/dda/jobs_routes.py
CHANGED
|
@@ -120,6 +120,7 @@ def get_job(job_id: int, db: Session = Depends(get_db)):
|
|
| 120 |
raise
|
| 121 |
except Exception as exc:
|
| 122 |
logger.warning("Could not load full run for job %s: %s", job_id, exc)
|
|
|
|
| 123 |
return data
|
| 124 |
|
| 125 |
|
|
@@ -140,6 +141,7 @@ def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
|
|
| 140 |
if overlay_file.exists():
|
| 141 |
overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
|
| 142 |
|
|
|
|
| 143 |
from .detect_service import _isoformat_ist
|
| 144 |
|
| 145 |
return {
|
|
@@ -160,7 +162,9 @@ def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
|
|
| 160 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 161 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 162 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
|
|
|
| 163 |
"createdAt": _isoformat_ist(run.created_at),
|
|
|
|
| 164 |
}
|
| 165 |
|
| 166 |
|
|
@@ -172,6 +176,8 @@ def list_jobs(
|
|
| 172 |
):
|
| 173 |
"""Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
|
| 174 |
_require_dda()
|
|
|
|
|
|
|
| 175 |
user = get_or_create_guest_user(db)
|
| 176 |
q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
|
| 177 |
if status:
|
|
|
|
| 120 |
raise
|
| 121 |
except Exception as exc:
|
| 122 |
logger.warning("Could not load full run for job %s: %s", job_id, exc)
|
| 123 |
+
data["resultError"] = str(exc)[:500]
|
| 124 |
return data
|
| 125 |
|
| 126 |
|
|
|
|
| 141 |
if overlay_file.exists():
|
| 142 |
overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
|
| 143 |
|
| 144 |
+
from .config import get_detection_max_side
|
| 145 |
from .detect_service import _isoformat_ist
|
| 146 |
|
| 147 |
return {
|
|
|
|
| 162 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 163 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 164 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
| 165 |
+
"afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
|
| 166 |
"createdAt": _isoformat_ist(run.created_at),
|
| 167 |
+
"detectionMaxSide": get_detection_max_side(),
|
| 168 |
}
|
| 169 |
|
| 170 |
|
|
|
|
| 176 |
):
|
| 177 |
"""Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
|
| 178 |
_require_dda()
|
| 179 |
+
from .job_runner import reconcile_stale_jobs
|
| 180 |
+
reconcile_stale_jobs(db)
|
| 181 |
user = get_or_create_guest_user(db)
|
| 182 |
q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
|
| 183 |
if status:
|
app/dda/library_routes.py
CHANGED
|
@@ -69,6 +69,7 @@ def dda_config():
|
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
|
|
|
| 72 |
"maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
| 73 |
"maxUploadGb": round(max_gb, 2),
|
| 74 |
"maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
|
|
|
| 69 |
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 70 |
return {
|
| 71 |
"mode": "dda",
|
| 72 |
+
"appMode": "dda",
|
| 73 |
"maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
| 74 |
"maxUploadGb": round(max_gb, 2),
|
| 75 |
"maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
app/dda/report_pdf.py
CHANGED
|
@@ -38,6 +38,7 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
|
|
| 38 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 39 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 40 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
|
|
|
| 41 |
"createdAt": _isoformat_ist(run.created_at),
|
| 42 |
"pdfUrl": f"/api/dda/reports/{run.id}/pdf",
|
| 43 |
}
|
|
@@ -49,7 +50,7 @@ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False, r
|
|
| 49 |
return payload
|
| 50 |
|
| 51 |
|
| 52 |
-
def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
|
| 53 |
"""Build PDF bytes and suggested download filename."""
|
| 54 |
from reportlab.lib import colors
|
| 55 |
from reportlab.lib.pagesizes import A4
|
|
@@ -58,7 +59,10 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
|
|
| 58 |
from reportlab.platypus import Image as RLImage
|
| 59 |
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
buf = BytesIO()
|
| 63 |
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
|
| 64 |
styles = getSampleStyleSheet()
|
|
@@ -102,10 +106,9 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
|
|
| 102 |
|
| 103 |
story.append(Paragraph("Detected regions", styles["Heading3"]))
|
| 104 |
if regions:
|
| 105 |
-
table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng"]]
|
| 106 |
for r in regions[:50]:
|
| 107 |
-
lat =
|
| 108 |
-
lng = r.get("longitude") or r.get("lng")
|
| 109 |
table_data.append([
|
| 110 |
str(r.get("id", "")),
|
| 111 |
r.get("ddaChangeType") or r.get("objectType") or "—",
|
|
@@ -114,6 +117,7 @@ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
|
|
| 114 |
f'{r.get("area", 0):,}',
|
| 115 |
f"{lat:.5f}" if lat is not None else "—",
|
| 116 |
f"{lng:.5f}" if lng is not None else "—",
|
|
|
|
| 117 |
])
|
| 118 |
tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
|
| 119 |
tbl.setStyle(TableStyle([
|
|
|
|
| 38 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 39 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 40 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
| 41 |
+
"afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
|
| 42 |
"createdAt": _isoformat_ist(run.created_at),
|
| 43 |
"pdfUrl": f"/api/dda/reports/{run.id}/pdf",
|
| 44 |
}
|
|
|
|
| 50 |
return payload
|
| 51 |
|
| 52 |
|
| 53 |
+
def generate_report_pdf(run: DetectionRun, *, regions: Optional[List[dict]] = None) -> tuple[bytes, str]:
|
| 54 |
"""Build PDF bytes and suggested download filename."""
|
| 55 |
from reportlab.lib import colors
|
| 56 |
from reportlab.lib.pagesizes import A4
|
|
|
|
| 59 |
from reportlab.platypus import Image as RLImage
|
| 60 |
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
| 61 |
|
| 62 |
+
from .geo_regions import region_lat_lng
|
| 63 |
+
|
| 64 |
+
if regions is None:
|
| 65 |
+
regions = json.loads(run.regions_json or "[]")
|
| 66 |
buf = BytesIO()
|
| 67 |
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
|
| 68 |
styles = getSampleStyleSheet()
|
|
|
|
| 106 |
|
| 107 |
story.append(Paragraph("Detected regions", styles["Heading3"]))
|
| 108 |
if regions:
|
| 109 |
+
table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng", "Review"]]
|
| 110 |
for r in regions[:50]:
|
| 111 |
+
lat, lng = region_lat_lng(r)
|
|
|
|
| 112 |
table_data.append([
|
| 113 |
str(r.get("id", "")),
|
| 114 |
r.get("ddaChangeType") or r.get("objectType") or "—",
|
|
|
|
| 117 |
f'{r.get("area", 0):,}',
|
| 118 |
f"{lat:.5f}" if lat is not None else "—",
|
| 119 |
f"{lng:.5f}" if lng is not None else "—",
|
| 120 |
+
r.get("reviewStatus") or "pending",
|
| 121 |
])
|
| 122 |
tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
|
| 123 |
tbl.setStyle(TableStyle([
|
app/dda/reports_routes.py
CHANGED
|
@@ -55,8 +55,9 @@ def download_report_pdf(run_id: int, db: Session = Depends(get_db)):
|
|
| 55 |
_require_dda()
|
| 56 |
user = get_or_create_guest_user(db)
|
| 57 |
run = _get_user_run(db, run_id, user.id)
|
|
|
|
| 58 |
try:
|
| 59 |
-
pdf_bytes, filename = generate_report_pdf(run)
|
| 60 |
except ImportError as exc:
|
| 61 |
raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
|
| 62 |
except Exception as exc:
|
|
@@ -79,8 +80,7 @@ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get
|
|
| 79 |
_require_dda()
|
| 80 |
user = get_or_create_guest_user(db)
|
| 81 |
run = _get_user_run(db, run_id, user.id)
|
| 82 |
-
|
| 83 |
-
regions = json.loads(run.regions_json or "[]")
|
| 84 |
report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
|
| 85 |
sent, error = send_notification(
|
| 86 |
recipient=body.email.strip(),
|
|
|
|
| 55 |
_require_dda()
|
| 56 |
user = get_or_create_guest_user(db)
|
| 57 |
run = _get_user_run(db, run_id, user.id)
|
| 58 |
+
regions = merge_reviews(db, run.id, load_regions(run))
|
| 59 |
try:
|
| 60 |
+
pdf_bytes, filename = generate_report_pdf(run, regions=regions)
|
| 61 |
except ImportError as exc:
|
| 62 |
raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
|
| 63 |
except Exception as exc:
|
|
|
|
| 80 |
_require_dda()
|
| 81 |
user = get_or_create_guest_user(db)
|
| 82 |
run = _get_user_run(db, run_id, user.id)
|
| 83 |
+
regions = merge_reviews(db, run.id, load_regions(run))
|
|
|
|
| 84 |
report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
|
| 85 |
sent, error = send_notification(
|
| 86 |
recipient=body.email.strip(),
|
app/dda/review_routes.py
CHANGED
|
@@ -124,10 +124,7 @@ def submit_confirmed(run_id: int, db: Session = Depends(get_db)):
|
|
| 124 |
raise HTTPException(status_code=502, detail=result.message)
|
| 125 |
|
| 126 |
submitted_ids = [int(r["id"]) for r in confirmed if r.get("id") is not None]
|
| 127 |
-
|
| 128 |
-
mark_confirmed_submitted(db, run.id, submitted_ids)
|
| 129 |
-
for r in confirmed:
|
| 130 |
-
r["reviewStatus"] = "submitted"
|
| 131 |
|
| 132 |
return {
|
| 133 |
"ok": True,
|
|
|
|
| 124 |
raise HTTPException(status_code=502, detail=result.message)
|
| 125 |
|
| 126 |
submitted_ids = [int(r["id"]) for r in confirmed if r.get("id") is not None]
|
| 127 |
+
mark_confirmed_submitted(db, run.id, submitted_ids)
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
return {
|
| 130 |
"ok": True,
|
app/main.py
CHANGED
|
@@ -485,7 +485,11 @@ def get_run(
|
|
| 485 |
regions = _load_regions_json(run.regions_json)
|
| 486 |
if IS_DDA_MODE:
|
| 487 |
from .dda.review_service import merge_reviews
|
|
|
|
| 488 |
regions = merge_reviews(db, run.id, regions)
|
|
|
|
|
|
|
|
|
|
| 489 |
return {
|
| 490 |
"id": run.id,
|
| 491 |
"title": run.title,
|
|
@@ -503,7 +507,9 @@ def get_run(
|
|
| 503 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if (getattr(run, "before_full_path", None) or "").strip() else None,
|
| 504 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (getattr(run, "before_thumb_path", None) or "").strip() else None,
|
| 505 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (getattr(run, "after_thumb_path", None) or "").strip() else None,
|
|
|
|
| 506 |
"createdAt": _isoformat_ist(run.created_at),
|
|
|
|
| 507 |
}
|
| 508 |
|
| 509 |
|
|
@@ -546,12 +552,17 @@ def delete_run(
|
|
| 546 |
if not run:
|
| 547 |
raise HTTPException(status_code=404, detail="Run not found")
|
| 548 |
# Delete overlay and thumbnail files if they exist
|
| 549 |
-
for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path"):
|
| 550 |
path_val = getattr(run, path_attr, None)
|
| 551 |
if path_val:
|
| 552 |
f = OVERLAYS_DIR.parent / path_val
|
| 553 |
if f.exists():
|
| 554 |
f.unlink(missing_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
db.delete(run)
|
| 556 |
db.commit()
|
| 557 |
return {"ok": True, "deleted_id": run_id}
|
|
|
|
| 485 |
regions = _load_regions_json(run.regions_json)
|
| 486 |
if IS_DDA_MODE:
|
| 487 |
from .dda.review_service import merge_reviews
|
| 488 |
+
from .dda.config import get_detection_max_side
|
| 489 |
regions = merge_reviews(db, run.id, regions)
|
| 490 |
+
detection_max_side = get_detection_max_side()
|
| 491 |
+
else:
|
| 492 |
+
detection_max_side = None
|
| 493 |
return {
|
| 494 |
"id": run.id,
|
| 495 |
"title": run.title,
|
|
|
|
| 507 |
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if (getattr(run, "before_full_path", None) or "").strip() else None,
|
| 508 |
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (getattr(run, "before_thumb_path", None) or "").strip() else None,
|
| 509 |
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (getattr(run, "after_thumb_path", None) or "").strip() else None,
|
| 510 |
+
"afterFullUrl": f"/api/overlay/{run.after_full_path}" if (getattr(run, "after_full_path", None) or "").strip() else None,
|
| 511 |
"createdAt": _isoformat_ist(run.created_at),
|
| 512 |
+
"detectionMaxSide": detection_max_side,
|
| 513 |
}
|
| 514 |
|
| 515 |
|
|
|
|
| 552 |
if not run:
|
| 553 |
raise HTTPException(status_code=404, detail="Run not found")
|
| 554 |
# Delete overlay and thumbnail files if they exist
|
| 555 |
+
for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path", "after_full_path"):
|
| 556 |
path_val = getattr(run, path_attr, None)
|
| 557 |
if path_val:
|
| 558 |
f = OVERLAYS_DIR.parent / path_val
|
| 559 |
if f.exists():
|
| 560 |
f.unlink(missing_ok=True)
|
| 561 |
+
if IS_DDA_MODE:
|
| 562 |
+
from .dda.models import DetectionJob, RegionReview
|
| 563 |
+
db.query(RegionReview).filter(RegionReview.run_id == run_id).delete()
|
| 564 |
+
for job in db.query(DetectionJob).filter(DetectionJob.run_id == run_id).all():
|
| 565 |
+
job.run_id = None
|
| 566 |
db.delete(run)
|
| 567 |
db.commit()
|
| 568 |
return {"ok": True, "deleted_id": run_id}
|
app/models.py
CHANGED
|
@@ -36,6 +36,7 @@ class DetectionRun(Base):
|
|
| 36 |
before_full_path = Column(String(512), default="")
|
| 37 |
before_thumb_path = Column(String(512), default="")
|
| 38 |
after_thumb_path = Column(String(512), default="")
|
|
|
|
| 39 |
zone = Column(String(128), default="")
|
| 40 |
village = Column(String(128), default="")
|
| 41 |
regions_json = Column(Text, default="[]")
|
|
|
|
| 36 |
before_full_path = Column(String(512), default="")
|
| 37 |
before_thumb_path = Column(String(512), default="")
|
| 38 |
after_thumb_path = Column(String(512), default="")
|
| 39 |
+
after_full_path = Column(String(512), default="")
|
| 40 |
zone = Column(String(128), default="")
|
| 41 |
village = Column(String(128), default="")
|
| 42 |
regions_json = Column(Text, default="[]")
|
library_sources/2026/.gitkeep
CHANGED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
static/css/dda.css
CHANGED
|
@@ -478,3 +478,17 @@
|
|
| 478 |
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
|
| 479 |
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
| 480 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
|
| 479 |
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
| 480 |
}
|
| 481 |
+
|
| 482 |
+
.regions-table-wrap {
|
| 483 |
+
overflow-x: auto;
|
| 484 |
+
max-width: 100%;
|
| 485 |
+
}
|
| 486 |
+
.regions-table-wrap .regions-table {
|
| 487 |
+
min-width: 960px;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
@media (max-width: 768px) {
|
| 491 |
+
.dda-compare-slots {
|
| 492 |
+
grid-template-columns: 1fr;
|
| 493 |
+
}
|
| 494 |
+
}
|
static/js/dda/app.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
| 1 |
const API = '';
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
async function ddaApi(method, path, options = {}) {
|
| 4 |
const headers = { ...options.headers };
|
| 5 |
if (options.body && !(options.body instanceof FormData)) {
|
|
@@ -9,7 +27,7 @@ async function ddaApi(method, path, options = {}) {
|
|
| 9 |
const text = await res.text();
|
| 10 |
let data = null;
|
| 11 |
try { data = text ? JSON.parse(text) : null; } catch (_) {}
|
| 12 |
-
if (!res.ok) throw new Error(data?.detail || res.statusText || 'Request failed');
|
| 13 |
return data;
|
| 14 |
}
|
| 15 |
|
|
@@ -113,10 +131,16 @@ async function initDda() {
|
|
| 113 |
uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
|
| 114 |
}
|
| 115 |
|
| 116 |
-
|
|
|
|
| 117 |
showDdaError('DDA mode is off. Run locally with: python run.py');
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
const yearsData = await ddaApi('GET', '/api/dda/local/years');
|
| 121 |
localYears = yearsData.years || [];
|
| 122 |
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
|
@@ -137,7 +161,8 @@ async function loadHierarchyTree() {
|
|
| 137 |
el.innerHTML = '<p class="dim">No zones seeded.</p>';
|
| 138 |
return;
|
| 139 |
}
|
| 140 |
-
el.innerHTML =
|
|
|
|
| 141 |
const villages = z.villages || [];
|
| 142 |
const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
|
| 143 |
const villageItems = villages.map((v) =>
|
|
@@ -180,11 +205,11 @@ async function loadLibraryImages() {
|
|
| 180 |
grid.innerHTML = items.map((img) => {
|
| 181 |
const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
|
| 182 |
return `
|
| 183 |
-
<div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '"')}" title="${img.filename}">
|
| 184 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 185 |
<div class="meta">
|
| 186 |
-
<strong>${img.year}</strong><br/>
|
| 187 |
-
${img.filename}<br/>
|
| 188 |
<span class="dim">${formatBytes(img.fileSizeBytes)}</span>
|
| 189 |
</div>
|
| 190 |
</div>`;
|
|
|
|
| 1 |
const API = '';
|
| 2 |
|
| 3 |
+
function escapeHtml(text) {
|
| 4 |
+
if (text == null) return '';
|
| 5 |
+
return String(text)
|
| 6 |
+
.replace(/&/g, '&')
|
| 7 |
+
.replace(/</g, '<')
|
| 8 |
+
.replace(/>/g, '>')
|
| 9 |
+
.replace(/"/g, '"');
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function formatApiError(detail) {
|
| 13 |
+
if (!detail) return null;
|
| 14 |
+
if (typeof detail === 'string') return detail;
|
| 15 |
+
if (Array.isArray(detail)) {
|
| 16 |
+
return detail.map((d) => (d && d.msg) || JSON.stringify(d)).join('; ');
|
| 17 |
+
}
|
| 18 |
+
return String(detail);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
async function ddaApi(method, path, options = {}) {
|
| 22 |
const headers = { ...options.headers };
|
| 23 |
if (options.body && !(options.body instanceof FormData)) {
|
|
|
|
| 27 |
const text = await res.text();
|
| 28 |
let data = null;
|
| 29 |
try { data = text ? JSON.parse(text) : null; } catch (_) {}
|
| 30 |
+
if (!res.ok) throw new Error(formatApiError(data?.detail) || res.statusText || 'Request failed');
|
| 31 |
return data;
|
| 32 |
}
|
| 33 |
|
|
|
|
| 131 |
uploadLimit.textContent = `Files on your computer are not on the server. Upload .tif images here (up to ${localCfg.maxUploadGb} GB each).`;
|
| 132 |
}
|
| 133 |
|
| 134 |
+
const appMode = ddaConfig.appMode || ddaConfig.mode || localCfg.appMode || 'dda';
|
| 135 |
+
if (!localCfg.isHosted && appMode !== 'dda') {
|
| 136 |
showDdaError('DDA mode is off. Run locally with: python run.py');
|
| 137 |
}
|
| 138 |
|
| 139 |
+
const urlTab = new URLSearchParams(window.location.search).get('tab');
|
| 140 |
+
if (urlTab) {
|
| 141 |
+
document.querySelector(`.dda-tab[data-tab="${urlTab}"]`)?.click();
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
const yearsData = await ddaApi('GET', '/api/dda/local/years');
|
| 145 |
localYears = yearsData.years || [];
|
| 146 |
if (typeof renderYearTree === 'function') renderYearTree(localYears);
|
|
|
|
| 161 |
el.innerHTML = '<p class="dim">No zones seeded.</p>';
|
| 162 |
return;
|
| 163 |
}
|
| 164 |
+
el.innerHTML = `<p class="dim" style="margin-bottom:0.5rem">DB hierarchy (upload API). Local library uses year folders.</p>` +
|
| 165 |
+
zones.map((z) => {
|
| 166 |
const villages = z.villages || [];
|
| 167 |
const zoneCount = villages.reduce((s, v) => s + (v.imageCount || 0), 0);
|
| 168 |
const villageItems = villages.map((v) =>
|
|
|
|
| 205 |
grid.innerHTML = items.map((img) => {
|
| 206 |
const thumb = img.thumbUrl ? img.thumbUrl.replace(/path=[^&]+/, 'path=' + encodeURIComponent(img.path)) : '';
|
| 207 |
return `
|
| 208 |
+
<div class="dda-card-img" draggable="true" data-image-path="${img.path.replace(/"/g, '"')}" title="${escapeHtml(img.filename)}">
|
| 209 |
${thumb ? `<img src="${thumb}" alt="" loading="lazy" />` : '<div class="meta">No preview</div>'}
|
| 210 |
<div class="meta">
|
| 211 |
+
<strong>${escapeHtml(String(img.year))}</strong><br/>
|
| 212 |
+
${escapeHtml(img.filename)}<br/>
|
| 213 |
<span class="dim">${formatBytes(img.fileSizeBytes)}</span>
|
| 214 |
</div>
|
| 215 |
</div>`;
|
static/js/dda/compare.js
CHANGED
|
@@ -306,7 +306,9 @@ async function runDetectionWithFallback(form, loadingEl) {
|
|
| 306 |
return await pollJobUntilDone(queued.jobId, loadingEl);
|
| 307 |
} catch (err) {
|
| 308 |
const msg = String(err.message || '');
|
| 309 |
-
|
|
|
|
|
|
|
| 310 |
loadingEl.textContent = 'Running detection (sync fallback)…';
|
| 311 |
}
|
| 312 |
}
|
|
@@ -321,9 +323,17 @@ async function pollJobUntilDone(jobId, loadingEl) {
|
|
| 321 |
if (loadingEl) {
|
| 322 |
loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
|
| 323 |
}
|
| 324 |
-
if (status === 'completed'
|
| 325 |
-
if (
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
}
|
| 328 |
if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
|
| 329 |
await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
| 306 |
return await pollJobUntilDone(queued.jobId, loadingEl);
|
| 307 |
} catch (err) {
|
| 308 |
const msg = String(err.message || '');
|
| 309 |
+
const useSync = msg.includes('Not Found') || msg.includes('404')
|
| 310 |
+
|| msg.includes('503') || msg.includes('409') || msg.includes('busy');
|
| 311 |
+
if (!useSync) throw err;
|
| 312 |
loadingEl.textContent = 'Running detection (sync fallback)…';
|
| 313 |
}
|
| 314 |
}
|
|
|
|
| 323 |
if (loadingEl) {
|
| 324 |
loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
|
| 325 |
}
|
| 326 |
+
if (status === 'completed') {
|
| 327 |
+
if (job.result) {
|
| 328 |
+
if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
|
| 329 |
+
return { result: job.result, jobId };
|
| 330 |
+
}
|
| 331 |
+
if (job.runId) {
|
| 332 |
+
const data = await ddaApi('GET', `/api/history/${job.runId}`);
|
| 333 |
+
if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
|
| 334 |
+
return { result: data, jobId };
|
| 335 |
+
}
|
| 336 |
+
if (job.resultError) throw new Error(job.resultError);
|
| 337 |
}
|
| 338 |
if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
|
| 339 |
await new Promise((r) => setTimeout(r, 2000));
|
static/js/dda/report_page.js
CHANGED
|
@@ -18,6 +18,13 @@ function formatCoord(v) {
|
|
| 18 |
return Number.isFinite(n) ? n.toFixed(5) : '—';
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
async function loadReportPage() {
|
| 22 |
const runId = parseReportRunId();
|
| 23 |
const loading = document.getElementById('report-loading');
|
|
@@ -58,16 +65,19 @@ async function loadReportPage() {
|
|
| 58 |
const regions = data.regions || [];
|
| 59 |
if (tbody) {
|
| 60 |
tbody.innerHTML = regions.length
|
| 61 |
-
? regions.map((r) =>
|
|
|
|
|
|
|
| 62 |
<tr>
|
| 63 |
<td>${r.id ?? ''}</td>
|
| 64 |
<td>${r.ddaChangeType || r.objectType || '—'}</td>
|
| 65 |
<td>${r.internalObjectType || r.objectType || '—'}</td>
|
| 66 |
<td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
|
| 67 |
<td>${(r.area ?? 0).toLocaleString()}</td>
|
| 68 |
-
<td>${formatCoord(
|
| 69 |
-
<td>${formatCoord(
|
| 70 |
-
</tr>`
|
|
|
|
| 71 |
: '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
|
| 72 |
}
|
| 73 |
|
|
@@ -81,8 +91,8 @@ async function loadReportPage() {
|
|
| 81 |
if (viewBtn) {
|
| 82 |
viewBtn.disabled = false;
|
| 83 |
viewBtn.onclick = () => {
|
| 84 |
-
window.location.href = '/?tab=reports';
|
| 85 |
try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
|
|
|
|
| 86 |
};
|
| 87 |
}
|
| 88 |
|
|
|
|
| 18 |
return Number.isFinite(n) ? n.toFixed(5) : '—';
|
| 19 |
}
|
| 20 |
|
| 21 |
+
function regionLatLng(r) {
|
| 22 |
+
const ll = r.latLng || {};
|
| 23 |
+
const lat = r.latitude ?? ll.lat;
|
| 24 |
+
const lng = r.longitude ?? ll.lng;
|
| 25 |
+
return { lat, lng };
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
async function loadReportPage() {
|
| 29 |
const runId = parseReportRunId();
|
| 30 |
const loading = document.getElementById('report-loading');
|
|
|
|
| 65 |
const regions = data.regions || [];
|
| 66 |
if (tbody) {
|
| 67 |
tbody.innerHTML = regions.length
|
| 68 |
+
? regions.map((r) => {
|
| 69 |
+
const { lat, lng } = regionLatLng(r);
|
| 70 |
+
return `
|
| 71 |
<tr>
|
| 72 |
<td>${r.id ?? ''}</td>
|
| 73 |
<td>${r.ddaChangeType || r.objectType || '—'}</td>
|
| 74 |
<td>${r.internalObjectType || r.objectType || '—'}</td>
|
| 75 |
<td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
|
| 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="7" class="dim">No regions detected.</td></tr>';
|
| 82 |
}
|
| 83 |
|
|
|
|
| 91 |
if (viewBtn) {
|
| 92 |
viewBtn.disabled = false;
|
| 93 |
viewBtn.onclick = () => {
|
|
|
|
| 94 |
try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
|
| 95 |
+
window.location.href = '/?tab=reports';
|
| 96 |
};
|
| 97 |
}
|
| 98 |
|
static/js/dda/reports.js
CHANGED
|
@@ -75,7 +75,7 @@ async function loadReportsList() {
|
|
| 75 |
${rows.map((r) => `
|
| 76 |
<tr>
|
| 77 |
<td>${formatReportDate(r.createdAt)}</td>
|
| 78 |
-
<td>${r.title}</td>
|
| 79 |
<td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
|
| 80 |
<td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
|
| 81 |
<td>${r.regions ?? '—'}</td>
|
|
|
|
| 75 |
${rows.map((r) => `
|
| 76 |
<tr>
|
| 77 |
<td>${formatReportDate(r.createdAt)}</td>
|
| 78 |
+
<td>${escapeHtml(r.title)}</td>
|
| 79 |
<td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
|
| 80 |
<td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
|
| 81 |
<td>${r.regions ?? '—'}</td>
|
static/js/dda/result.js
CHANGED
|
@@ -74,7 +74,7 @@ function updateDdaReviewSummary(regions) {
|
|
| 74 |
const s = r.reviewStatus || 'pending';
|
| 75 |
counts[s] = (counts[s] || 0) + 1;
|
| 76 |
});
|
| 77 |
-
el.textContent = `Review: ${counts.confirmed} confirmed · ${counts.false_positive} false positive · ${counts.pending} pending`;
|
| 78 |
}
|
| 79 |
|
| 80 |
function setupDdaReviewBar(runId, regions) {
|
|
@@ -156,7 +156,7 @@ function showDdaResult(data) {
|
|
| 156 |
? 'data:image/png;base64,' + data.overlayBase64Png
|
| 157 |
: (data.overlayUrl || '');
|
| 158 |
const beforeSrc = data.beforeFullUrl || data.beforeThumbUrl || '';
|
| 159 |
-
const afterSrc = data.
|
| 160 |
|
| 161 |
ddaViewUrls = { before: beforeSrc, after: afterSrc, overlay: overlaySrc };
|
| 162 |
setDdaViewMode('slider');
|
|
@@ -176,7 +176,7 @@ function showDdaResult(data) {
|
|
| 176 |
beforeImg.onload = onReady;
|
| 177 |
setTimeout(() => { resetDdaCompareSlider(); resetDdaZoom(); }, 500);
|
| 178 |
|
| 179 |
-
const regions =
|
| 180 |
ddaRegionList = regions;
|
| 181 |
ddaRegionRows = regions.map((r) => {
|
| 182 |
const tr = document.createElement('tr');
|
|
|
|
| 74 |
const s = r.reviewStatus || 'pending';
|
| 75 |
counts[s] = (counts[s] || 0) + 1;
|
| 76 |
});
|
| 77 |
+
el.textContent = `Review: ${counts.confirmed} confirmed · ${counts.false_positive} false positive · ${counts.submitted || 0} submitted · ${counts.pending} pending`;
|
| 78 |
}
|
| 79 |
|
| 80 |
function setupDdaReviewBar(runId, regions) {
|
|
|
|
| 156 |
? 'data:image/png;base64,' + data.overlayBase64Png
|
| 157 |
: (data.overlayUrl || '');
|
| 158 |
const beforeSrc = data.beforeFullUrl || data.beforeThumbUrl || '';
|
| 159 |
+
const afterSrc = data.afterFullUrl || data.afterThumbUrl || beforeSrc;
|
| 160 |
|
| 161 |
ddaViewUrls = { before: beforeSrc, after: afterSrc, overlay: overlaySrc };
|
| 162 |
setDdaViewMode('slider');
|
|
|
|
| 176 |
beforeImg.onload = onReady;
|
| 177 |
setTimeout(() => { resetDdaCompareSlider(); resetDdaZoom(); }, 500);
|
| 178 |
|
| 179 |
+
const regions = data.regions || [];
|
| 180 |
ddaRegionList = regions;
|
| 181 |
ddaRegionRows = regions.map((r) => {
|
| 182 |
const tr = document.createElement('tr');
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -259,11 +259,11 @@
|
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
|
| 262 |
-
<script src="/static/js/dda/app.js?v=
|
| 263 |
<script src="/static/js/dda/library.js?v=6"></script>
|
| 264 |
-
<script src="/static/js/dda/result.js?v=
|
| 265 |
-
<script src="/static/js/dda/compare.js?v=
|
| 266 |
-
<script src="/static/js/dda/reports.js?v=
|
| 267 |
<script src="/static/js/dda/notifications.js?v=1"></script>
|
| 268 |
</body>
|
| 269 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=11" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
|
| 262 |
+
<script src="/static/js/dda/app.js?v=12"></script>
|
| 263 |
<script src="/static/js/dda/library.js?v=6"></script>
|
| 264 |
+
<script src="/static/js/dda/result.js?v=6"></script>
|
| 265 |
+
<script src="/static/js/dda/compare.js?v=9"></script>
|
| 266 |
+
<script src="/static/js/dda/reports.js?v=4"></script>
|
| 267 |
<script src="/static/js/dda/notifications.js?v=1"></script>
|
| 268 |
</body>
|
| 269 |
</html>
|
templates/report_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Detection Report</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app dda-report-page">
|
|
@@ -62,6 +62,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>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Detection Report</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=11" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app dda-report-page">
|
|
|
|
| 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=2"></script>
|
| 66 |
</body>
|
| 67 |
</html>
|