coderuday21 Cursor commited on
Commit
d9820a1
·
1 Parent(s): 214c544

Add detection progress bar with staged job polling for DDA compare tab.

Browse files
app/dda/detect_service.py CHANGED
@@ -80,8 +80,14 @@ def run_detection_and_save(
80
  max_size: Optional[int] = None,
81
  geo_bounds_path: Optional[Path] = None,
82
  user_id: Optional[int] = None,
 
83
  ) -> dict:
84
  from ..detection_engine import run_detection
 
 
 
 
 
85
 
86
  if user_id:
87
  from ..auth import get_user_by_id
@@ -94,6 +100,12 @@ def run_detection_and_save(
94
  if min_region_area is not None:
95
  min_region_area = int(max(50, min(10000, min_region_area)))
96
 
 
 
 
 
 
 
97
  change_mask, result_image, stats, change_regions = run_detection(
98
  before_pil,
99
  after_pil,
@@ -103,9 +115,10 @@ def run_detection_and_save(
103
  detection_sensitivity=detection_sensitivity,
104
  min_region_area=min_region_area,
105
  max_size=max_size,
 
106
  )
107
 
108
- # Save before image at detection resolution (matches overlay coordinates for slider)
109
  from ..detection_engine import preprocess_image, get_detection_max_size
110
 
111
  before_for_slider = Image.fromarray(
@@ -178,10 +191,12 @@ def run_detection_and_save(
178
  db.commit()
179
  db.refresh(run)
180
 
 
181
  overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
182
  notification_sent = False
183
  notification_error = None
184
  if notify_email and notify_email.strip():
 
185
  from .config import IS_DDA_MODE, get_public_base_url
186
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
187
  notification_sent, notification_error = send_notification(
@@ -197,6 +212,8 @@ def run_detection_and_save(
197
  report_url=report_url,
198
  )
199
 
 
 
200
  return {
201
  "id": run.id,
202
  "title": run.title,
 
80
  max_size: Optional[int] = None,
81
  geo_bounds_path: Optional[Path] = None,
82
  user_id: Optional[int] = None,
83
+ job_id: Optional[int] = None,
84
  ) -> dict:
85
  from ..detection_engine import run_detection
86
+ from .job_progress import update_job_progress
87
+
88
+ def _report(pct: int, stage: str) -> None:
89
+ if job_id is not None:
90
+ update_job_progress(job_id, pct, stage)
91
 
92
  if user_id:
93
  from ..auth import get_user_by_id
 
100
  if min_region_area is not None:
101
  min_region_area = int(max(50, min(10000, min_region_area)))
102
 
103
+ def _on_engine_progress(engine_pct: int, stage: str) -> None:
104
+ # Map engine 0–100% into job 15–78%
105
+ job_pct = 15 + int(engine_pct * 0.63)
106
+ _report(job_pct, stage)
107
+
108
+ _report(15, "Running detection")
109
  change_mask, result_image, stats, change_regions = run_detection(
110
  before_pil,
111
  after_pil,
 
115
  detection_sensitivity=detection_sensitivity,
116
  min_region_area=min_region_area,
117
  max_size=max_size,
118
+ on_progress=_on_engine_progress,
119
  )
120
 
121
+ _report(80, "Saving results")
122
  from ..detection_engine import preprocess_image, get_detection_max_size
123
 
124
  before_for_slider = Image.fromarray(
 
191
  db.commit()
192
  db.refresh(run)
193
 
194
+ _report(90, "Preparing report")
195
  overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
196
  notification_sent = False
197
  notification_error = None
198
  if notify_email and notify_email.strip():
199
+ _report(95, "Sending notification")
200
  from .config import IS_DDA_MODE, get_public_base_url
201
  report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
202
  notification_sent, notification_error = send_notification(
 
212
  report_url=report_url,
213
  )
214
 
215
+ _report(100, "Complete")
216
+
217
  return {
218
  "id": run.id,
219
  "title": run.title,
app/dda/job_progress.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persist detection job progress for UI polling."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+
7
+ from ..database import SessionLocal
8
+ from .models import DetectionJob
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def update_job_progress(job_id: int, pct: int, stage: str) -> None:
14
+ """Update progress_pct and progress_stage in job params_json."""
15
+ db = SessionLocal()
16
+ try:
17
+ job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
18
+ if not job or job.status not in ("queued", "running"):
19
+ return
20
+ params = {}
21
+ try:
22
+ params = json.loads(job.params_json or "{}")
23
+ except json.JSONDecodeError:
24
+ pass
25
+ params["progress_pct"] = max(0, min(100, int(pct)))
26
+ params["progress_stage"] = stage or ""
27
+ job.params_json = json.dumps(params)
28
+ db.commit()
29
+ except Exception as exc:
30
+ logger.warning("Could not update job %d progress: %s", job_id, exc)
31
+ db.rollback()
32
+ finally:
33
+ db.close()
34
+
35
+
36
+ def get_job_progress(params: dict, status: str) -> tuple[int, str]:
37
+ pct = params.get("progress_pct")
38
+ stage = params.get("progress_stage") or ""
39
+ if status == "completed":
40
+ return 100, stage or "Complete"
41
+ if status == "failed":
42
+ return int(pct) if pct is not None else 0, stage or "Failed"
43
+ if status == "queued":
44
+ return int(pct) if pct is not None else 0, stage or "Queued"
45
+ if pct is None:
46
+ return 10, stage or "Running"
47
+ return int(pct), stage or "Running"
app/dda/job_runner.py CHANGED
@@ -17,6 +17,7 @@ from ..models import DetectionRun
17
  from .config import get_detection_max_side
18
  from .detect_service import run_detection_and_save
19
  from .geotiff_io import load_rgb_pil
 
20
  from .local_routes import safe_resolve
21
  from .models import DetectionJob
22
 
@@ -60,6 +61,7 @@ def _run_job_sync(job_id: int) -> None:
60
  job.started_at = _utcnow()
61
  job.error_message = ""
62
  db.commit()
 
63
 
64
  params = _parse_params(job)
65
  base_path = params.get("base_path", "")
@@ -67,7 +69,9 @@ def _run_job_sync(job_id: int) -> None:
67
  if not base_path or not comparison_path:
68
  raise ValueError("Job missing base_path or comparison_path in params_json")
69
 
 
70
  before_pil, after_pil, base_file = _load_pair(base_path, comparison_path)
 
71
  title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}"
72
  result = run_detection_and_save(
73
  db,
@@ -85,8 +89,11 @@ def _run_job_sync(job_id: int) -> None:
85
  max_size=get_detection_max_side(),
86
  geo_bounds_path=base_file,
87
  user_id=job.created_by,
 
88
  )
89
 
 
 
90
  job.status = "completed"
91
  job.run_id = result["id"]
92
  job.completed_at = _utcnow()
@@ -212,7 +219,10 @@ def create_local_folder_job(
212
 
213
 
214
  def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
 
 
215
  params = _parse_params(job)
 
216
  out = {
217
  "id": job.id,
218
  "status": job.status,
@@ -223,6 +233,8 @@ def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
223
  "runId": job.run_id,
224
  "errorMessage": job.error_message or "",
225
  "notifyEmail": job.notify_email or "",
 
 
226
  "createdAt": job.created_at.isoformat() if job.created_at else None,
227
  "startedAt": job.started_at.isoformat() if job.started_at else None,
228
  "completedAt": job.completed_at.isoformat() if job.completed_at else None,
 
17
  from .config import get_detection_max_side
18
  from .detect_service import run_detection_and_save
19
  from .geotiff_io import load_rgb_pil
20
+ from .job_progress import update_job_progress
21
  from .local_routes import safe_resolve
22
  from .models import DetectionJob
23
 
 
61
  job.started_at = _utcnow()
62
  job.error_message = ""
63
  db.commit()
64
+ update_job_progress(job_id, 5, "Starting job")
65
 
66
  params = _parse_params(job)
67
  base_path = params.get("base_path", "")
 
69
  if not base_path or not comparison_path:
70
  raise ValueError("Job missing base_path or comparison_path in params_json")
71
 
72
+ update_job_progress(job_id, 8, "Loading images")
73
  before_pil, after_pil, base_file = _load_pair(base_path, comparison_path)
74
+ update_job_progress(job_id, 12, "Images loaded")
75
  title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}"
76
  result = run_detection_and_save(
77
  db,
 
89
  max_size=get_detection_max_side(),
90
  geo_bounds_path=base_file,
91
  user_id=job.created_by,
92
+ job_id=job_id,
93
  )
94
 
95
+ update_job_progress(job_id, 100, "Complete")
96
+
97
  job.status = "completed"
98
  job.run_id = result["id"]
99
  job.completed_at = _utcnow()
 
219
 
220
 
221
  def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
222
+ from .job_progress import get_job_progress
223
+
224
  params = _parse_params(job)
225
+ progress_pct, progress_stage = get_job_progress(params, job.status)
226
  out = {
227
  "id": job.id,
228
  "status": job.status,
 
233
  "runId": job.run_id,
234
  "errorMessage": job.error_message or "",
235
  "notifyEmail": job.notify_email or "",
236
+ "progressPct": progress_pct,
237
+ "progressStage": progress_stage,
238
  "createdAt": job.created_at.isoformat() if job.created_at else None,
239
  "startedAt": job.started_at.isoformat() if job.started_at else None,
240
  "completedAt": job.completed_at.isoformat() if job.completed_at else None,
app/detection_engine.py CHANGED
@@ -2596,24 +2596,32 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
2596
  def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2597
  enable_registration=True, enable_normalization=True,
2598
  detection_sensitivity=0.5, min_region_area=None,
2599
- max_size=None):
2600
  """Run full detection pipeline; returns change_mask, result_image, stats, regions."""
 
 
 
 
2601
  ms = max_size or get_detection_max_size()
 
2602
  before_array = preprocess_image(before_pil, max_size=ms)
2603
  after_array = preprocess_image(after_pil, max_size=ms)
2604
 
2605
  registration_ok = False
2606
  reg_meta = {}
2607
  if enable_registration:
 
2608
  before_array, after_array, registration_ok, reg_meta = register_images(
2609
  before_array, after_array)
2610
  if enable_normalization:
 
2611
  before_array, after_array = normalize_radiometry(before_array, after_array)
2612
 
2613
  alignment_warning = None
2614
  if enable_registration and not registration_ok:
2615
  alignment_warning = ALIGNMENT_WARNING_MSG
2616
 
 
2617
  if method == "AI-Based Deep Learning":
2618
  change_mask, threshold_debug = ai_deep_learning_method(
2619
  before_array, after_array,
@@ -2650,6 +2658,7 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2650
  float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
2651
  )
2652
 
 
2653
  change_regions = analyze_change_regions(
2654
  change_mask,
2655
  after_array,
@@ -2681,12 +2690,14 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2681
  }
2682
 
2683
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
 
2684
  result_image = visualize_changes(
2685
  before_array, after_array, change_mask,
2686
  regions=change_regions, total_pixels=total_pixels,
2687
  )
2688
  changed_pixels = int(np.sum(change_mask > 127))
2689
  change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
 
2690
 
2691
  stats = {
2692
  "total_pixels": total_pixels,
 
2596
  def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2597
  enable_registration=True, enable_normalization=True,
2598
  detection_sensitivity=0.5, min_region_area=None,
2599
+ max_size=None, on_progress=None):
2600
  """Run full detection pipeline; returns change_mask, result_image, stats, regions."""
2601
+ def _prog(pct, stage):
2602
+ if on_progress:
2603
+ on_progress(int(pct), stage)
2604
+
2605
  ms = max_size or get_detection_max_size()
2606
+ _prog(5, "Preprocessing images")
2607
  before_array = preprocess_image(before_pil, max_size=ms)
2608
  after_array = preprocess_image(after_pil, max_size=ms)
2609
 
2610
  registration_ok = False
2611
  reg_meta = {}
2612
  if enable_registration:
2613
+ _prog(20, "Registering images")
2614
  before_array, after_array, registration_ok, reg_meta = register_images(
2615
  before_array, after_array)
2616
  if enable_normalization:
2617
+ _prog(35, "Normalizing radiometry")
2618
  before_array, after_array = normalize_radiometry(before_array, after_array)
2619
 
2620
  alignment_warning = None
2621
  if enable_registration and not registration_ok:
2622
  alignment_warning = ALIGNMENT_WARNING_MSG
2623
 
2624
+ _prog(50, f"Running {method}")
2625
  if method == "AI-Based Deep Learning":
2626
  change_mask, threshold_debug = ai_deep_learning_method(
2627
  before_array, after_array,
 
2658
  float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
2659
  )
2660
 
2661
+ _prog(65, "Analyzing change regions")
2662
  change_regions = analyze_change_regions(
2663
  change_mask,
2664
  after_array,
 
2690
  }
2691
 
2692
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
2693
+ _prog(85, "Building visualization")
2694
  result_image = visualize_changes(
2695
  before_array, after_array, change_mask,
2696
  regions=change_regions, total_pixels=total_pixels,
2697
  )
2698
  changed_pixels = int(np.sum(change_mask > 127))
2699
  change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
2700
+ _prog(95, "Finalizing results")
2701
 
2702
  stats = {
2703
  "total_pixels": total_pixels,
static/js/dda/compare.js CHANGED
@@ -297,33 +297,67 @@ function showDetectResult(data) {
297
  else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.');
298
  }
299
 
300
- async function runDetectionWithFallback(form, loadingEl) {
301
- const hosted = window.ddaState?.localCfg?.isHosted;
302
- if (hosted) {
303
- loadingEl.textContent = 'Queuing detection job…';
304
- try {
305
- const queued = await ddaApi('POST', '/api/dda/jobs', { body: form });
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
  }
315
- return ddaApi('POST', '/api/dda/detect/from-library', { body: form }).then((result) => ({ result, jobId: null }));
316
  }
317
 
318
- async function pollJobUntilDone(jobId, loadingEl) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  const maxAttempts = 600;
320
  for (let i = 0; i < maxAttempts; i++) {
321
  const job = await ddaApi('GET', `/api/dda/jobs/${jobId}`);
322
  const status = job.status;
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 };
@@ -347,11 +381,9 @@ async function runLibraryDetection() {
347
  return;
348
  }
349
  const btn = document.getElementById('btn-run-job');
350
- const loading = document.getElementById('dda-detect-loading');
351
  if (typeof hideDdaError === 'function') hideDdaError();
352
  btn.disabled = true;
353
- loading?.classList.remove('hidden');
354
- loading.textContent = 'Running detection…';
355
 
356
  const form = new FormData();
357
  form.append('base_path', compareState.t1.path);
@@ -370,7 +402,7 @@ async function runLibraryDetection() {
370
  }
371
 
372
  try {
373
- const { result: data, jobId } = await runDetectionWithFallback(form, loading);
374
  if (jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(jobId);
375
  showDetectResult(data);
376
  if (typeof showDdaSuccess === 'function') {
@@ -383,7 +415,7 @@ async function runLibraryDetection() {
383
  if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
384
  } finally {
385
  btn.disabled = !(compareState.t1 && compareState.t2);
386
- loading?.classList.add('hidden');
387
  }
388
  }
389
 
 
297
  else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.');
298
  }
299
 
300
+ function setDetectProgress(pct, stage) {
301
+ const fill = document.getElementById('detect-progress-fill');
302
+ const label = document.getElementById('detect-progress-label');
303
+ const clamped = Math.max(0, Math.min(100, Number(pct) || 0));
304
+ if (fill) fill.style.width = `${clamped}%`;
305
+ const text = stage ? `${stage} — ${clamped}%` : `${clamped}%`;
306
+ if (label) label.textContent = text;
307
+ }
308
+
309
+ function showDetectProgress() {
310
+ const wrap = document.getElementById('detect-progress');
311
+ wrap?.classList.remove('hidden');
312
+ setDetectProgress(0, 'Starting detection');
313
+ }
314
+
315
+ function hideDetectProgress(delayMs = 0) {
316
+ const hide = () => document.getElementById('detect-progress')?.classList.add('hidden');
317
+ if (delayMs > 0) setTimeout(hide, delayMs);
318
+ else hide();
319
+ }
320
+
321
+ async function runDetectionWithFallback(form) {
322
+ setDetectProgress(2, 'Queuing detection job');
323
+ try {
324
+ const queued = await ddaApi('POST', '/api/dda/jobs', { body: form });
325
+ return await pollJobUntilDone(queued.jobId);
326
+ } catch (err) {
327
+ const msg = String(err.message || '');
328
+ const useSync = msg.includes('Not Found') || msg.includes('404')
329
+ || msg.includes('503') || msg.includes('409') || msg.includes('busy');
330
+ if (!useSync) throw err;
331
+ return runSyncDetectionWithProgress(form);
332
  }
 
333
  }
334
 
335
+ async function runSyncDetectionWithProgress(form) {
336
+ let pct = 5;
337
+ setDetectProgress(pct, 'Running detection (sync)');
338
+ const timer = setInterval(() => {
339
+ pct = Math.min(92, pct + (pct < 50 ? 4 : 2));
340
+ setDetectProgress(pct, 'Running detection (sync)');
341
+ }, 1500);
342
+ try {
343
+ const result = await ddaApi('POST', '/api/dda/detect/from-library', { body: form });
344
+ setDetectProgress(100, 'Complete');
345
+ return { result, jobId: null };
346
+ } finally {
347
+ clearInterval(timer);
348
+ }
349
+ }
350
+
351
+ async function pollJobUntilDone(jobId) {
352
  const maxAttempts = 600;
353
  for (let i = 0; i < maxAttempts; i++) {
354
  const job = await ddaApi('GET', `/api/dda/jobs/${jobId}`);
355
  const status = job.status;
356
+ const pct = job.progressPct ?? (status === 'queued' ? 0 : 10);
357
+ const stage = job.progressStage || (status === 'queued' ? 'Queued' : 'Running');
358
+ setDetectProgress(pct, stage);
359
  if (status === 'completed') {
360
+ setDetectProgress(100, 'Complete');
361
  if (job.result) {
362
  if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
363
  return { result: job.result, jobId };
 
381
  return;
382
  }
383
  const btn = document.getElementById('btn-run-job');
 
384
  if (typeof hideDdaError === 'function') hideDdaError();
385
  btn.disabled = true;
386
+ showDetectProgress();
 
387
 
388
  const form = new FormData();
389
  form.append('base_path', compareState.t1.path);
 
402
  }
403
 
404
  try {
405
+ const { result: data, jobId } = await runDetectionWithFallback(form);
406
  if (jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(jobId);
407
  showDetectResult(data);
408
  if (typeof showDdaSuccess === 'function') {
 
415
  if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
416
  } finally {
417
  btn.disabled = !(compareState.t1 && compareState.t2);
418
+ hideDetectProgress(2000);
419
  }
420
  }
421
 
templates/index_dda.html CHANGED
@@ -159,7 +159,10 @@
159
  </div>
160
  <p class="dim" id="dda-detect-res-hint"></p>
161
  <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
162
- <p id="dda-detect-loading" class="dim hidden" style="margin-top:0.75rem">Detection runs as a background job — large GeoTIFFs may take several minutes…</p>
 
 
 
163
  </div>
164
  </section>
165
 
@@ -306,7 +309,7 @@
306
  <script src="/static/js/dda/tree.js?v=1"></script>
307
  <script src="/static/js/dda/library.js?v=8"></script>
308
  <script src="/static/js/dda/result.js?v=6"></script>
309
- <script src="/static/js/dda/compare.js?v=9"></script>
310
  <script src="/static/js/dda/reports.js?v=4"></script>
311
  <script src="/static/js/dda/notifications.js?v=1"></script>
312
  </body>
 
159
  </div>
160
  <p class="dim" id="dda-detect-res-hint"></p>
161
  <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
162
+ <div id="detect-progress" class="dda-upload-progress hidden" style="margin-top:0.75rem">
163
+ <div class="dda-progress-bar"><div id="detect-progress-fill" class="dda-progress-fill"></div></div>
164
+ <span id="detect-progress-label" class="dim">Starting detection…</span>
165
+ </div>
166
  </div>
167
  </section>
168
 
 
309
  <script src="/static/js/dda/tree.js?v=1"></script>
310
  <script src="/static/js/dda/library.js?v=8"></script>
311
  <script src="/static/js/dda/result.js?v=6"></script>
312
+ <script src="/static/js/dda/compare.js?v=10"></script>
313
  <script src="/static/js/dda/reports.js?v=4"></script>
314
  <script src="/static/js/dda/notifications.js?v=1"></script>
315
  </body>