coderuday21 commited on
Commit
b31590e
·
1 Parent(s): 19f4fba

History table view, zoom, severity markers, row hover highlight, before/after thumbnails

Browse files
app/detection_engine.py CHANGED
@@ -418,11 +418,42 @@ def _clean_mask(mask, sensitivity=0.5):
418
 
419
 
420
  # ---------------------------------------------------------------------------
421
- # 9. Improved visualization
422
  # ---------------------------------------------------------------------------
423
 
424
- def visualize_changes(img1, img2, change_mask, regions=None):
425
- """Overlay change mask on 'after' image in RED."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  if img1.shape != img2.shape:
427
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
428
  if change_mask.shape[:2] != img2.shape[:2]:
@@ -432,44 +463,35 @@ def visualize_changes(img1, img2, change_mask, regions=None):
432
  mask_bool = change_mask > 127
433
  mask_float = mask_bool.astype(np.float32)
434
 
435
- # Red overlay for all detected changes
436
  red_layer = np.zeros_like(img2, dtype=np.float32)
437
- red_layer[:, :, 0] = 255 # pure red
438
  alpha = 0.50
439
  for c in range(3):
440
  overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
441
 
442
- # Draw outlines and labels for each region
 
443
  if regions:
444
  overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
445
  for r in regions:
446
  x, y, w, h = r["bbox"]
447
- cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), (255, 255, 255), 1)
448
-
449
- # Build annotation label from sub-type and 3D info
450
- parts = []
451
- sub = r.get("sub_type")
452
- if sub:
453
- parts.append(sub)
454
- stories = r.get("estimated_stories")
455
- stage = r.get("construction_stage")
456
- if stories is not None:
457
- parts.append(f"{stories}F")
458
- if stage and stage != "Unknown":
459
- parts.append(stage)
460
-
461
- if parts:
462
- label = " | ".join(parts)
463
- font = cv2.FONT_HERSHEY_SIMPLEX
464
- font_scale = max(0.30, min(0.50, w / 220))
465
- thickness = 1
466
- (tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
467
- lx = x
468
- ly = max(th + 4, y - 6)
469
- cv2.rectangle(overlay_uint8, (lx, ly - th - 4), (lx + tw + 6, ly + 2),
470
- (0, 0, 0), cv2.FILLED)
471
- cv2.putText(overlay_uint8, label, (lx + 3, ly - 2), font,
472
- font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
473
  return overlay_uint8
474
 
475
  return np.clip(overlay, 0, 255).astype(np.uint8)
@@ -1413,6 +1435,12 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1413
  change_regions.append(region)
1414
 
1415
  change_regions.sort(key=lambda r: r["area"], reverse=True)
 
 
 
 
 
 
1416
  return change_regions
1417
 
1418
 
@@ -1444,9 +1472,11 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1444
  change_mask, after_array, min_area=200, before_img=before_array
1445
  )
1446
 
1447
- result_image = visualize_changes(before_array, after_array, change_mask, regions=change_regions)
1448
-
1449
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
 
 
 
 
1450
  changed_pixels = int(np.sum(change_mask > 127))
1451
  change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
1452
 
 
418
 
419
 
420
  # ---------------------------------------------------------------------------
421
+ # 9. Severity classification and improved visualization
422
  # ---------------------------------------------------------------------------
423
 
424
+ def _severity_from_region(region, total_pixels):
425
+ """
426
+ Classify change severity from area and confidence.
427
+ Green = minor, Yellow = moderate, Red = major.
428
+ Used for color-coded bounding boxes and table summary.
429
+ """
430
+ area = region.get("area", 0)
431
+ confidence = region.get("confidence", 0.0)
432
+ if total_pixels <= 0:
433
+ return "minor"
434
+ area_ratio = area / total_pixels
435
+ # Combined score: larger area and higher confidence -> more severe
436
+ score = area_ratio * 500 + confidence * 2
437
+ if score < 0.5:
438
+ return "minor"
439
+ if score < 1.2:
440
+ return "moderate"
441
+ return "major"
442
+
443
+
444
+ # BGR colors for severity (OpenCV uses BGR)
445
+ _SEVERITY_COLORS = {
446
+ "minor": (0, 200, 0), # Green
447
+ "moderate": (0, 255, 255), # Yellow
448
+ "major": (0, 0, 255), # Red
449
+ }
450
+
451
+
452
+ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
453
+ """
454
+ Overlay change mask on 'after' image; draw color-coded bounding boxes
455
+ by severity (green=minor, yellow=moderate, red=major) and numbered labels.
456
+ """
457
  if img1.shape != img2.shape:
458
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
459
  if change_mask.shape[:2] != img2.shape[:2]:
 
463
  mask_bool = change_mask > 127
464
  mask_float = mask_bool.astype(np.float32)
465
 
466
+ # Red overlay for all detected change pixels
467
  red_layer = np.zeros_like(img2, dtype=np.float32)
468
+ red_layer[:, :, 0] = 255
469
  alpha = 0.50
470
  for c in range(3):
471
  overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
472
 
473
+ total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
474
+
475
  if regions:
476
  overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
477
  for r in regions:
478
  x, y, w, h = r["bbox"]
479
+ severity = r.get("severity") or _severity_from_region(r, total_px)
480
+ color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
481
+
482
+ # Color-coded bounding box (thicker for visibility)
483
+ cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, 2)
484
+
485
+ # Numbered label matching summary table (region ID)
486
+ rid = r.get("id", 0)
487
+ label = str(rid)
488
+ font = cv2.FONT_HERSHEY_SIMPLEX
489
+ font_scale = max(0.4, min(0.7, w / 150))
490
+ thickness = 2
491
+ (tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
492
+ lx, ly = x, max(th + 4, y - 4)
493
+ cv2.rectangle(overlay_uint8, (lx, ly - th - 4), (lx + tw + 8, ly + 2), (0, 0, 0), cv2.FILLED)
494
+ cv2.putText(overlay_uint8, label, (lx + 4, ly - 2), font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
 
 
 
 
 
 
 
 
 
 
495
  return overlay_uint8
496
 
497
  return np.clip(overlay, 0, 255).astype(np.uint8)
 
1435
  change_regions.append(region)
1436
 
1437
  change_regions.sort(key=lambda r: r["area"], reverse=True)
1438
+
1439
+ # Assign severity for color-coded display and table summary
1440
+ total_px = change_mask.shape[0] * change_mask.shape[1]
1441
+ for r in change_regions:
1442
+ r["severity"] = _severity_from_region(r, total_px)
1443
+
1444
  return change_regions
1445
 
1446
 
 
1472
  change_mask, after_array, min_area=200, before_img=before_array
1473
  )
1474
 
 
 
1475
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
1476
+ result_image = visualize_changes(
1477
+ before_array, after_array, change_mask,
1478
+ regions=change_regions, total_pixels=total_pixels
1479
+ )
1480
  changed_pixels = int(np.sum(change_mask > 127))
1481
  change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
1482
 
app/main.py CHANGED
@@ -33,8 +33,12 @@ Base.metadata.create_all(bind=engine, checkfirst=True)
33
 
34
  # Lightweight migration: add columns introduced after initial schema
35
  with engine.connect() as conn:
36
- for col, col_type in [("zone", "VARCHAR(128) DEFAULT ''"),
37
- ("village", "VARCHAR(128) DEFAULT ''")]:
 
 
 
 
38
  try:
39
  conn.execute(sa_text(
40
  f"ALTER TABLE detection_runs ADD COLUMN {col} {col_type}"))
@@ -49,6 +53,7 @@ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
49
  TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
50
  OVERLAYS_DIR = DATA_DIR / "overlays"
51
  OVERLAYS_DIR.mkdir(parents=True, exist_ok=True)
 
52
 
53
  if STATIC_DIR.exists():
54
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@@ -208,11 +213,25 @@ async def detect(
208
  change_mask, result_image, stats, change_regions = run_detection(
209
  before_pil, after_pil, method=method, enable_registration=enable_registration, enable_normalization=enable_normalization
210
  )
211
- # Save overlay to disk and store path (optional)
212
- overlay_filename = f"{user.id}_{uuid.uuid4().hex}.png"
 
213
  overlay_path = OVERLAYS_DIR / overlay_filename
214
  Image.fromarray(result_image).save(overlay_path)
215
  relative_overlay = f"overlays/{overlay_filename}"
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  regions_serializable = [
217
  {
218
  "id": int(r["id"]),
@@ -221,6 +240,7 @@ async def detect(
221
  "bbox": {"x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3])},
222
  "objectType": str(r["object_type"]),
223
  "confidence": float(r["confidence"]),
 
224
  "subType": r.get("sub_type"),
225
  "subTypeConfidence": float(r["sub_type_confidence"]) if r.get("sub_type_confidence") is not None else None,
226
  "estimatedStories": r.get("estimated_stories"),
@@ -244,6 +264,8 @@ async def detect(
244
  change_percentage=change_pct,
245
  regions_count=len(change_regions),
246
  overlay_path=relative_overlay,
 
 
247
  regions_json=json.dumps(regions_serializable),
248
  )
249
  db.add(run)
@@ -285,6 +307,8 @@ async def detect(
285
  "regions": regions_serializable,
286
  "overlayBase64Png": overlay_b64,
287
  "overlayUrl": f"/api/overlay/{relative_overlay}",
 
 
288
  "notificationSent": notification_sent,
289
  "createdAt": run.created_at.isoformat(),
290
  }
@@ -322,13 +346,50 @@ def history(
322
  "village": r.village or "",
323
  "changePercentage": r.change_percentage,
324
  "regionsCount": r.regions_count,
 
 
325
  "overlayUrl": f"/api/overlay/{r.overlay_path}" if r.overlay_path else None,
 
 
326
  "createdAt": r.created_at.isoformat(),
327
  }
328
  for r in runs
329
  ]
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  # --- Delete history run ---
333
  @app.delete("/api/history/{run_id}")
334
  def delete_run(
@@ -341,11 +402,13 @@ def delete_run(
341
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
342
  if not run:
343
  raise HTTPException(status_code=404, detail="Run not found")
344
- # Delete overlay file if it exists
345
- if run.overlay_path:
346
- overlay_file = OVERLAYS_DIR.parent / run.overlay_path
347
- if overlay_file.exists():
348
- overlay_file.unlink(missing_ok=True)
 
 
349
  db.delete(run)
350
  db.commit()
351
  return {"ok": True, "deleted_id": run_id}
 
33
 
34
  # Lightweight migration: add columns introduced after initial schema
35
  with engine.connect() as conn:
36
+ for col, col_type in [
37
+ ("zone", "VARCHAR(128) DEFAULT ''"),
38
+ ("village", "VARCHAR(128) DEFAULT ''"),
39
+ ("before_thumb_path", "VARCHAR(512) DEFAULT ''"),
40
+ ("after_thumb_path", "VARCHAR(512) DEFAULT ''"),
41
+ ]:
42
  try:
43
  conn.execute(sa_text(
44
  f"ALTER TABLE detection_runs ADD COLUMN {col} {col_type}"))
 
53
  TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
54
  OVERLAYS_DIR = DATA_DIR / "overlays"
55
  OVERLAYS_DIR.mkdir(parents=True, exist_ok=True)
56
+ THUMB_MAX_SIZE = 200 # max width or height for history thumbnails
57
 
58
  if STATIC_DIR.exists():
59
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
 
213
  change_mask, result_image, stats, change_regions = run_detection(
214
  before_pil, after_pil, method=method, enable_registration=enable_registration, enable_normalization=enable_normalization
215
  )
216
+ # Save overlay and thumbnails for history table view
217
+ base_name = f"{user.id}_{uuid.uuid4().hex}"
218
+ overlay_filename = base_name + ".png"
219
  overlay_path = OVERLAYS_DIR / overlay_filename
220
  Image.fromarray(result_image).save(overlay_path)
221
  relative_overlay = f"overlays/{overlay_filename}"
222
+
223
+ # Save before/after thumbnails for history table (efficient small images)
224
+ before_thumb_file = OVERLAYS_DIR / f"{base_name}_before_thumb.png"
225
+ after_thumb_file = OVERLAYS_DIR / f"{base_name}_after_thumb.png"
226
+ before_thumb_pil = before_pil.copy()
227
+ before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
228
+ before_thumb_pil.save(before_thumb_file)
229
+ after_thumb_pil = after_pil.copy()
230
+ after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
231
+ after_thumb_pil.save(after_thumb_file)
232
+ relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
233
+ relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
234
+
235
  regions_serializable = [
236
  {
237
  "id": int(r["id"]),
 
240
  "bbox": {"x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3])},
241
  "objectType": str(r["object_type"]),
242
  "confidence": float(r["confidence"]),
243
+ "severity": r.get("severity", "minor"),
244
  "subType": r.get("sub_type"),
245
  "subTypeConfidence": float(r["sub_type_confidence"]) if r.get("sub_type_confidence") is not None else None,
246
  "estimatedStories": r.get("estimated_stories"),
 
264
  change_percentage=change_pct,
265
  regions_count=len(change_regions),
266
  overlay_path=relative_overlay,
267
+ before_thumb_path=relative_before_thumb,
268
+ after_thumb_path=relative_after_thumb,
269
  regions_json=json.dumps(regions_serializable),
270
  )
271
  db.add(run)
 
307
  "regions": regions_serializable,
308
  "overlayBase64Png": overlay_b64,
309
  "overlayUrl": f"/api/overlay/{relative_overlay}",
310
+ "beforeThumbUrl": f"/api/overlay/{relative_before_thumb}",
311
+ "afterThumbUrl": f"/api/overlay/{relative_after_thumb}",
312
  "notificationSent": notification_sent,
313
  "createdAt": run.created_at.isoformat(),
314
  }
 
346
  "village": r.village or "",
347
  "changePercentage": r.change_percentage,
348
  "regionsCount": r.regions_count,
349
+ "totalPixels": r.total_pixels,
350
+ "changedPixels": r.changed_pixels,
351
  "overlayUrl": f"/api/overlay/{r.overlay_path}" if r.overlay_path else None,
352
+ "beforeThumbUrl": f"/api/overlay/{r.before_thumb_path}" if (getattr(r, "before_thumb_path", None) or "").strip() else None,
353
+ "afterThumbUrl": f"/api/overlay/{r.after_thumb_path}" if (getattr(r, "after_thumb_path", None) or "").strip() else None,
354
  "createdAt": r.created_at.isoformat(),
355
  }
356
  for r in runs
357
  ]
358
 
359
 
360
+ @app.get("/api/history/{run_id}")
361
+ def get_run(
362
+ run_id: int,
363
+ user: Optional[User] = Depends(get_current_user),
364
+ db: Session = Depends(get_db),
365
+ ):
366
+ """Fetch a single run by id for opening from history (result view with slider, table, zoom)."""
367
+ if not user:
368
+ raise HTTPException(status_code=401, detail="Login required")
369
+ run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
370
+ if not run:
371
+ raise HTTPException(status_code=404, detail="Run not found")
372
+ regions = json.loads(run.regions_json) if run.regions_json else []
373
+ return {
374
+ "id": run.id,
375
+ "title": run.title,
376
+ "method": run.method,
377
+ "zone": run.zone or "",
378
+ "village": run.village or "",
379
+ "statistics": {
380
+ "totalPixels": run.total_pixels,
381
+ "changedPixels": run.changed_pixels,
382
+ "unchangedPixels": run.total_pixels - run.changed_pixels,
383
+ "changePercentage": run.change_percentage,
384
+ },
385
+ "regions": regions,
386
+ "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
387
+ "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (getattr(run, "before_thumb_path", None) or "").strip() else None,
388
+ "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (getattr(run, "after_thumb_path", None) or "").strip() else None,
389
+ "createdAt": run.created_at.isoformat(),
390
+ }
391
+
392
+
393
  # --- Delete history run ---
394
  @app.delete("/api/history/{run_id}")
395
  def delete_run(
 
402
  run = db.query(DetectionRun).filter(DetectionRun.id == run_id, DetectionRun.user_id == user.id).first()
403
  if not run:
404
  raise HTTPException(status_code=404, detail="Run not found")
405
+ # Delete overlay and thumbnail files if they exist
406
+ for path_attr in ("overlay_path", "before_thumb_path", "after_thumb_path"):
407
+ path_val = getattr(run, path_attr, None)
408
+ if path_val:
409
+ f = OVERLAYS_DIR.parent / path_val
410
+ if f.exists():
411
+ f.unlink(missing_ok=True)
412
  db.delete(run)
413
  db.commit()
414
  return {"ok": True, "deleted_id": run_id}
app/models.py CHANGED
@@ -28,7 +28,9 @@ class DetectionRun(Base):
28
  changed_pixels = Column(Integer, nullable=False)
29
  change_percentage = Column(Float, nullable=False)
30
  regions_count = Column(Integer, default=0)
31
- overlay_path = Column(String(512), default="") # optional: path to saved overlay image
 
 
32
  zone = Column(String(128), default="")
33
  village = Column(String(128), default="")
34
  regions_json = Column(Text, default="[]") # JSON list of regions
 
28
  changed_pixels = Column(Integer, nullable=False)
29
  change_percentage = Column(Float, nullable=False)
30
  regions_count = Column(Integer, default=0)
31
+ overlay_path = Column(String(512), default="")
32
+ before_thumb_path = Column(String(512), default="")
33
+ after_thumb_path = Column(String(512), default="")
34
  zone = Column(String(128), default="")
35
  village = Column(String(128), default="")
36
  regions_json = Column(Text, default="[]") # JSON list of regions
static/css/style.css CHANGED
@@ -594,6 +594,61 @@ input:focus, select:focus, textarea:focus {
594
  .stat-box-wide { grid-column: span 1; }
595
  }
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  /* ---- Compare slider ---- */
598
  .compare-slider {
599
  position: relative;
@@ -711,10 +766,63 @@ input:focus, select:focus, textarea:focus {
711
  }
712
  .regions-table td { color: var(--text-muted); }
713
  .regions-table tr:hover td { background: rgba(46, 51, 197, 0.04); }
 
714
  .regions-table td:nth-child(2) { color: var(--text); font-weight: 500; }
 
 
 
 
 
 
 
 
 
 
 
715
 
716
- /* ---- History ---- */
717
- .history-list { display: flex; flex-direction: column; gap: 0.5rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  .history-empty {
719
  text-align: center;
720
  color: var(--text-dim);
 
594
  .stat-box-wide { grid-column: span 1; }
595
  }
596
 
597
+ /* ---- Zoom + compare slider ---- */
598
+ .zoom-slider-section { margin-bottom: 1rem; }
599
+ .zoom-controls {
600
+ display: flex;
601
+ align-items: center;
602
+ gap: 0.5rem;
603
+ margin-bottom: 0.5rem;
604
+ }
605
+ .btn-zoom {
606
+ width: 32px;
607
+ height: 32px;
608
+ border-radius: var(--radius-sm);
609
+ border: 1px solid var(--border);
610
+ background: var(--bg-elevated);
611
+ font-size: 1.25rem;
612
+ line-height: 1;
613
+ cursor: pointer;
614
+ color: var(--text);
615
+ transition: all var(--transition);
616
+ }
617
+ .btn-zoom:hover {
618
+ border-color: var(--grad-start);
619
+ color: var(--grad-start);
620
+ background: var(--bg-hover);
621
+ }
622
+ .zoom-level {
623
+ font-size: 0.8rem;
624
+ color: var(--text-muted);
625
+ min-width: 3.5rem;
626
+ text-align: center;
627
+ }
628
+ .zoom-wrapper {
629
+ overflow: auto;
630
+ max-height: 70vh;
631
+ border-radius: var(--radius);
632
+ border: 1px solid var(--border);
633
+ background: var(--bg-elevated);
634
+ margin-top: 0.75rem;
635
+ }
636
+ .zoom-wrapper .compare-slider { min-height: 280px; margin-top: 0; }
637
+ .region-highlight-overlay {
638
+ position: absolute;
639
+ top: 0; left: 0; right: 0; bottom: 0;
640
+ pointer-events: none;
641
+ z-index: 5;
642
+ }
643
+ .region-highlight-overlay .highlight-box {
644
+ position: absolute;
645
+ border: 3px solid rgba(255,255,0,0.9);
646
+ background: rgba(255,255,0,0.15);
647
+ box-shadow: 0 0 12px rgba(255,255,0,0.5);
648
+ border-radius: 2px;
649
+ transition: opacity 0.15s ease;
650
+ }
651
+
652
  /* ---- Compare slider ---- */
653
  .compare-slider {
654
  position: relative;
 
766
  }
767
  .regions-table td { color: var(--text-muted); }
768
  .regions-table tr:hover td { background: rgba(46, 51, 197, 0.04); }
769
+ .regions-table tr.region-hover td { background: rgba(255, 255, 0, 0.12); }
770
  .regions-table td:nth-child(2) { color: var(--text); font-weight: 500; }
771
+ .severity-badge {
772
+ display: inline-block;
773
+ padding: 0.15rem 0.5rem;
774
+ border-radius: 12px;
775
+ font-size: 0.7rem;
776
+ font-weight: 600;
777
+ text-transform: capitalize;
778
+ }
779
+ .severity-badge.minor { background: rgba(0, 200, 0, 0.2); color: #0a6b0a; }
780
+ .severity-badge.moderate { background: rgba(255, 200, 0, 0.25); color: #8b6914; }
781
+ .severity-badge.major { background: rgba(255, 0, 0, 0.2); color: #b91c1c; }
782
 
783
+ /* ---- History table ---- */
784
+ .history-table-wrap { overflow-x: auto; margin-top: 0.5rem; }
785
+ .history-table {
786
+ width: 100%;
787
+ border-collapse: collapse;
788
+ font-size: 0.85rem;
789
+ }
790
+ .history-table th, .history-table td {
791
+ padding: 0.5rem 0.6rem;
792
+ border-bottom: 1px solid var(--border);
793
+ vertical-align: middle;
794
+ }
795
+ .history-table th {
796
+ color: var(--text-dim);
797
+ font-weight: 600;
798
+ font-size: 0.7rem;
799
+ text-transform: uppercase;
800
+ letter-spacing: 0.05em;
801
+ background: var(--bg-elevated);
802
+ text-align: left;
803
+ }
804
+ .history-table tbody tr {
805
+ cursor: pointer;
806
+ transition: background var(--transition);
807
+ }
808
+ .history-table tbody tr:hover {
809
+ background: var(--bg-hover);
810
+ }
811
+ .history-table .thumb-cell {
812
+ width: 64px;
813
+ padding: 0.35rem;
814
+ }
815
+ .history-table .thumb-cell img {
816
+ width: 56px;
817
+ height: 56px;
818
+ object-fit: cover;
819
+ border-radius: var(--radius-xs);
820
+ border: 1px solid var(--border);
821
+ display: block;
822
+ }
823
+ .history-table .stats-cell { white-space: nowrap; color: var(--text-muted); font-size: 0.8rem; }
824
+ .history-table .actions-cell { white-space: nowrap; }
825
+ .history-table .actions-cell button { margin-left: 0.25rem; }
826
  .history-empty {
827
  text-align: center;
828
  color: var(--text-dim);
static/js/app.js CHANGED
@@ -371,11 +371,16 @@ function formatCompact(n) {
371
  return n.toLocaleString();
372
  }
373
 
 
 
 
374
  function showResult(data) {
375
  const card = document.getElementById('result-card');
376
  const statsEl = document.getElementById('result-stats');
377
  const tbody = document.getElementById('regions-tbody');
378
 
 
 
379
  const locParts = [data.village, data.zone].filter(Boolean);
380
  const locLabel = locParts.length ? locParts.join(', ') : '—';
381
 
@@ -389,40 +394,90 @@ function showResult(data) {
389
 
390
  const beforeImg = document.getElementById('compare-before-img');
391
  const afterImg = document.getElementById('compare-after-img');
392
- const beforeFile = document.getElementById('file-before').files?.[0];
393
- if (beforeFile) readFileAsDataURL(beforeFile).then((url) => { beforeImg.src = url; });
394
-
395
- afterImg.src = data.overlayBase64Png
396
- ? 'data:image/png;base64,' + data.overlayBase64Png
397
- : (data.overlayUrl || '');
 
 
398
 
399
  resetCompareSlider();
 
400
 
401
  tbody.innerHTML = '';
402
- (data.regions || []).slice(0, 50).forEach((r) => {
 
403
  const tr = document.createElement('tr');
 
404
  const subType = r.subType || '—';
 
405
  const stories = r.estimatedStories != null ? r.estimatedStories : '—';
406
  const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : '—';
407
  const stage = r.constructionStage && r.constructionStage !== 'Unknown' ? r.constructionStage : '—';
 
408
  tr.innerHTML = `
409
  <td>${r.id}</td>
410
  <td>${r.objectType}</td>
411
  <td>${subType}</td>
 
412
  <td>${(r.confidence * 100).toFixed(1)}%</td>
413
  <td>${r.area.toLocaleString()}</td>
 
414
  <td>${stories}</td>
415
  <td>${height}</td>
416
  <td>${stage}</td>
417
- <td>(${r.center.x}, ${r.center.y})</td>
418
  `;
419
  tbody.appendChild(tr);
420
  });
421
 
 
422
  card.classList.remove('hidden');
423
  card.scrollIntoView({ behavior: 'smooth' });
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  // ---- Compare slider ----
427
  function initCompareSlider() {
428
  const slider = document.getElementById('compare-slider');
@@ -452,40 +507,107 @@ function resetCompareSlider() {
452
  if (h) h.style.left = '50%';
453
  }
454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  initCompareSlider();
456
 
457
- // ---- History with delete ----
458
  async function loadHistory() {
459
- const list = document.getElementById('history-list');
460
- if (!list) return;
 
 
461
  try {
462
  const items = await api('GET', '/api/history');
463
  if (!items || items.length === 0) {
464
- list.innerHTML = '<div class="history-empty">No detection runs yet. Upload images above to get started.</div>';
 
465
  return;
466
  }
467
- list.innerHTML = items.map((r) => {
468
- const loc = [r.village, r.zone].filter(Boolean).join(', ');
 
 
 
 
469
  return `
470
- <div class="history-item" data-id="${r.id}">
471
- <div class="history-info">
472
- <div class="history-title">${escapeHtml(r.title)}</div>
473
- <div class="history-meta">
474
- <span class="tag">${r.method}</span>
475
- ${loc ? `<span class="tag tag-loc">${escapeHtml(loc)}</span>` : ''}
476
- ${r.changePercentage.toFixed(2)}% changed &middot; ${r.regionsCount} regions &middot; ${formatDate(r.createdAt)}
477
- </div>
478
- </div>
479
- <div class="history-actions">
480
- ${r.overlayUrl ? `<a href="${r.overlayUrl}" target="_blank" class="btn btn-secondary btn-sm">View</a>` : ''}
481
- <button class="btn-icon" title="Delete this run" onclick="confirmDelete(${r.id})">
482
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
483
  </button>
484
- </div>
485
- </div>`;
486
  }).join('');
 
 
 
 
 
 
 
 
487
  } catch (_) {
488
- list.innerHTML = '<div class="history-empty">Could not load history.</div>';
 
 
 
 
 
 
 
 
 
 
489
  }
490
  }
491
 
@@ -515,13 +637,11 @@ document.getElementById('modal-confirm')?.addEventListener('click', async () =>
515
  pendingDeleteId = null;
516
  try {
517
  await api('DELETE', `/api/history/${id}`);
518
- // Animate removal
519
- const item = document.querySelector(`.history-item[data-id="${id}"]`);
520
- if (item) {
521
- item.style.transition = 'all 0.3s ease';
522
- item.style.opacity = '0';
523
- item.style.transform = 'translateX(20px)';
524
- setTimeout(() => { item.remove(); loadHistory(); }, 300);
525
  } else {
526
  loadHistory();
527
  }
 
371
  return n.toLocaleString();
372
  }
373
 
374
+ // Store current result for zoom and region hover (bbox in % for overlay)
375
+ let currentResultData = null;
376
+
377
  function showResult(data) {
378
  const card = document.getElementById('result-card');
379
  const statsEl = document.getElementById('result-stats');
380
  const tbody = document.getElementById('regions-tbody');
381
 
382
+ currentResultData = data;
383
+
384
  const locParts = [data.village, data.zone].filter(Boolean);
385
  const locLabel = locParts.length ? locParts.join(', ') : '—';
386
 
 
394
 
395
  const beforeImg = document.getElementById('compare-before-img');
396
  const afterImg = document.getElementById('compare-after-img');
397
+ if (data.overlayBase64Png) {
398
+ afterImg.src = 'data:image/png;base64,' + data.overlayBase64Png;
399
+ const beforeFile = document.getElementById('file-before').files?.[0];
400
+ if (beforeFile) readFileAsDataURL(beforeFile).then((url) => { beforeImg.src = url; });
401
+ } else {
402
+ afterImg.src = data.overlayUrl || '';
403
+ beforeImg.src = data.beforeThumbUrl || data.overlayUrl || '';
404
+ }
405
 
406
  resetCompareSlider();
407
+ resetZoom();
408
 
409
  tbody.innerHTML = '';
410
+ const regions = (data.regions || []).slice(0, 50);
411
+ regions.forEach((r) => {
412
  const tr = document.createElement('tr');
413
+ tr.dataset.regionId = r.id;
414
  const subType = r.subType || '—';
415
+ const severity = (r.severity || 'minor').toLowerCase();
416
  const stories = r.estimatedStories != null ? r.estimatedStories : '—';
417
  const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : '—';
418
  const stage = r.constructionStage && r.constructionStage !== 'Unknown' ? r.constructionStage : '—';
419
+ const coords = `(${r.center.x}, ${r.center.y})`;
420
  tr.innerHTML = `
421
  <td>${r.id}</td>
422
  <td>${r.objectType}</td>
423
  <td>${subType}</td>
424
+ <td><span class="severity-badge ${severity}">${severity}</span></td>
425
  <td>${(r.confidence * 100).toFixed(1)}%</td>
426
  <td>${r.area.toLocaleString()}</td>
427
+ <td>${coords}</td>
428
  <td>${stories}</td>
429
  <td>${height}</td>
430
  <td>${stage}</td>
 
431
  `;
432
  tbody.appendChild(tr);
433
  });
434
 
435
+ setupRegionHover(tbody, regions);
436
  card.classList.remove('hidden');
437
  card.scrollIntoView({ behavior: 'smooth' });
438
  }
439
 
440
+ function setupRegionHover(tbody, regions) {
441
+ const overlay = document.getElementById('region-highlight-overlay');
442
+ if (!overlay) return;
443
+ overlay.innerHTML = '';
444
+ tbody.querySelectorAll('tr[data-region-id]').forEach((tr) => {
445
+ tr.addEventListener('mouseenter', () => {
446
+ const id = parseInt(tr.dataset.regionId, 10);
447
+ const r = regions.find((x) => x.id === id);
448
+ if (!r || !r.bbox) return;
449
+ tbody.querySelectorAll('tr').forEach((row) => row.classList.remove('region-hover'));
450
+ tr.classList.add('region-hover');
451
+ const box = document.createElement('div');
452
+ box.className = 'highlight-box';
453
+ const imgEl = document.getElementById('compare-after-img');
454
+ if (!imgEl || !imgEl.offsetWidth) return;
455
+ const slider = document.getElementById('compare-slider');
456
+ if (!slider) return;
457
+ const rw = slider.offsetWidth;
458
+ const rh = slider.offsetHeight;
459
+ const imgW = imgEl.naturalWidth || 1;
460
+ const imgH = imgEl.naturalHeight || 1;
461
+ const scaleX = rw / imgW;
462
+ const scaleY = rh / imgH;
463
+ const scale = Math.min(scaleX, scaleY);
464
+ const drawW = imgW * scale;
465
+ const drawH = imgH * scale;
466
+ const offsetX = (rw - drawW) / 2;
467
+ const offsetY = (rh - drawH) / 2;
468
+ box.style.left = (offsetX + r.bbox.x * scale) + 'px';
469
+ box.style.top = (offsetY + r.bbox.y * scale) + 'px';
470
+ box.style.width = (r.bbox.w * scale) + 'px';
471
+ box.style.height = (r.bbox.h * scale) + 'px';
472
+ overlay.appendChild(box);
473
+ });
474
+ tr.addEventListener('mouseleave', () => {
475
+ tr.classList.remove('region-hover');
476
+ overlay.innerHTML = '';
477
+ });
478
+ });
479
+ }
480
+
481
  // ---- Compare slider ----
482
  function initCompareSlider() {
483
  const slider = document.getElementById('compare-slider');
 
507
  if (h) h.style.left = '50%';
508
  }
509
 
510
+ // ---- Zoom (result and history view) ----
511
+ let currentZoom = 1;
512
+ const ZOOM_MIN = 0.5;
513
+ const ZOOM_MAX = 3;
514
+ const ZOOM_STEP = 0.25;
515
+
516
+ function applyZoom() {
517
+ const slider = document.getElementById('compare-slider');
518
+ const levelEl = document.getElementById('zoom-level');
519
+ if (!slider) return;
520
+ slider.style.transform = `scale(${currentZoom})`;
521
+ slider.style.transformOrigin = 'center top';
522
+ if (levelEl) levelEl.textContent = Math.round(currentZoom * 100) + '%';
523
+ }
524
+
525
+ function resetZoom() {
526
+ currentZoom = 1;
527
+ applyZoom();
528
+ }
529
+
530
+ function initZoom() {
531
+ const zoomIn = document.getElementById('zoom-in');
532
+ const zoomOut = document.getElementById('zoom-out');
533
+ const wrapper = document.getElementById('zoom-wrapper');
534
+ if (zoomIn) zoomIn.addEventListener('click', () => {
535
+ currentZoom = Math.min(ZOOM_MAX, currentZoom + ZOOM_STEP);
536
+ applyZoom();
537
+ });
538
+ if (zoomOut) zoomOut.addEventListener('click', () => {
539
+ currentZoom = Math.max(ZOOM_MIN, currentZoom - ZOOM_STEP);
540
+ applyZoom();
541
+ });
542
+ if (wrapper) {
543
+ wrapper.addEventListener('wheel', (e) => {
544
+ if (!e.ctrlKey && !e.metaKey) return;
545
+ e.preventDefault();
546
+ if (e.deltaY < 0) currentZoom = Math.min(ZOOM_MAX, currentZoom + ZOOM_STEP);
547
+ else currentZoom = Math.max(ZOOM_MIN, currentZoom - ZOOM_STEP);
548
+ applyZoom();
549
+ }, { passive: false });
550
+ }
551
+ }
552
+ initZoom();
553
+
554
  initCompareSlider();
555
 
556
+ // ---- History table: load and row click to open result ----
557
  async function loadHistory() {
558
+ const tbody = document.getElementById('history-tbody');
559
+ const emptyEl = document.getElementById('history-empty');
560
+ const tableWrap = document.querySelector('.history-table-wrap');
561
+ if (!tbody) return;
562
  try {
563
  const items = await api('GET', '/api/history');
564
  if (!items || items.length === 0) {
565
+ if (tableWrap) tableWrap.classList.add('hidden');
566
+ if (emptyEl) { emptyEl.classList.remove('hidden'); emptyEl.textContent = 'No detection runs yet. Upload images above to get started.'; }
567
  return;
568
  }
569
+ if (tableWrap) tableWrap.classList.remove('hidden');
570
+ if (emptyEl) emptyEl.classList.add('hidden');
571
+ tbody.innerHTML = items.map((r) => {
572
+ const beforeThumb = r.beforeThumbUrl ? `<img src="${r.beforeThumbUrl}" alt="Before" loading="lazy" />` : '<span class="dim">—</span>';
573
+ const afterThumb = r.afterThumbUrl ? `<img src="${r.afterThumbUrl}" alt="After" loading="lazy" />` : '<span class="dim">—</span>';
574
+ const resultThumb = r.overlayUrl ? `<img src="${r.overlayUrl}" alt="Result" loading="lazy" />` : '<span class="dim">—</span>';
575
  return `
576
+ <tr data-run-id="${r.id}">
577
+ <td class="timestamp-cell">${formatDate(r.createdAt)}</td>
578
+ <td class="thumb-cell">${beforeThumb}</td>
579
+ <td class="thumb-cell">${afterThumb}</td>
580
+ <td class="thumb-cell">${resultThumb}</td>
581
+ <td class="stats-cell">${r.regionsCount} regions</td>
582
+ <td class="stats-cell">${r.changePercentage.toFixed(2)}%</td>
583
+ <td class="actions-cell">
584
+ ${r.overlayUrl ? `<a href="${r.overlayUrl}" target="_blank" class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">View</a>` : ''}
585
+ <button type="button" class="btn-icon" title="Delete" onclick="event.stopPropagation(); confirmDelete(${r.id})">
 
 
586
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
587
  </button>
588
+ </td>
589
+ </tr>`;
590
  }).join('');
591
+
592
+ tbody.querySelectorAll('tr[data-run-id]').forEach((tr) => {
593
+ tr.addEventListener('click', (e) => {
594
+ if (e.target.closest('.actions-cell')) return;
595
+ const id = parseInt(tr.dataset.runId, 10);
596
+ openRunFromHistory(id);
597
+ });
598
+ });
599
  } catch (_) {
600
+ if (tableWrap) tableWrap.classList.add('hidden');
601
+ if (emptyEl) { emptyEl.classList.remove('hidden'); emptyEl.textContent = 'Could not load history.'; }
602
+ }
603
+ }
604
+
605
+ async function openRunFromHistory(runId) {
606
+ try {
607
+ const data = await api('GET', '/api/history/' + runId);
608
+ showResult(data);
609
+ } catch (err) {
610
+ showError('dashboard-error', err.message || 'Failed to load run.');
611
  }
612
  }
613
 
 
637
  pendingDeleteId = null;
638
  try {
639
  await api('DELETE', `/api/history/${id}`);
640
+ const row = document.querySelector(`tr[data-run-id="${id}"]`);
641
+ if (row) {
642
+ row.style.transition = 'all 0.3s ease';
643
+ row.style.opacity = '0';
644
+ setTimeout(() => loadHistory(), 300);
 
 
645
  } else {
646
  loadHistory();
647
  }
templates/index.html CHANGED
@@ -151,11 +151,11 @@
151
  <div id="dashboard-error" class="alert alert-error hidden"></div>
152
  <div id="dashboard-success" class="alert alert-success hidden"></div>
153
 
154
- <!-- New run card -->
155
- <div class="card">
156
  <div class="card-header">
157
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
158
- <h3>New Detection Run</h3>
159
  </div>
160
  <form id="form-detect">
161
  <div class="location-row">
@@ -241,33 +241,45 @@
241
  </form>
242
  </div>
243
 
244
- <!-- Result card (shown after a run) -->
245
  <div class="card hidden" id="result-card">
246
  <div class="card-header">
247
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
248
- <h3>Detection Result</h3>
249
  </div>
250
  <div class="result-stats" id="result-stats"></div>
251
 
252
- <!-- Before / After comparison slider -->
253
- <div class="compare-slider" id="compare-slider">
254
- <div class="compare-before">
255
- <img id="compare-before-img" alt="Before" draggable="false" />
256
- <span class="compare-label compare-label-left">Before</span>
 
257
  </div>
258
- <div class="compare-after" id="compare-after-clip">
259
- <img id="compare-after-img" alt="Changes detected" draggable="false" />
260
- <span class="compare-label compare-label-right">Changes</span>
261
- </div>
262
- <div class="compare-handle" id="compare-handle">
263
- <div class="compare-handle-line"></div>
264
- <div class="compare-handle-knob">
265
- <svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 10l4-5v10z" fill="currentColor"/><path d="M14 10l-4-5v10z" fill="currentColor"/></svg>
 
 
 
 
 
 
 
 
 
 
 
266
  </div>
267
- <div class="compare-handle-line"></div>
268
  </div>
269
  </div>
270
 
 
271
  <div class="regions-table-wrap">
272
  <table class="regions-table" id="regions-table">
273
  <thead>
@@ -275,12 +287,13 @@
275
  <th>#</th>
276
  <th>Change Type</th>
277
  <th>Sub-Type</th>
 
278
  <th>Confidence</th>
279
  <th>Area (px)</th>
 
280
  <th>Stories</th>
281
  <th>Height</th>
282
  <th>Stage</th>
283
- <th>Center</th>
284
  </tr>
285
  </thead>
286
  <tbody id="regions-tbody"></tbody>
@@ -288,13 +301,29 @@
288
  </div>
289
  </div>
290
 
291
- <!-- History -->
292
- <div class="card mt-2">
293
  <div class="card-header">
294
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
295
- <h3>History</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  </div>
297
- <div id="history-list" class="history-list"></div>
298
  </div>
299
 
300
  </section>
@@ -312,6 +341,6 @@
312
  </div>
313
  </div>
314
 
315
- <script src="/static/js/app.js?v=15"></script>
316
  </body>
317
  </html>
 
151
  <div id="dashboard-error" class="alert alert-error hidden"></div>
152
  <div id="dashboard-success" class="alert alert-success hidden"></div>
153
 
154
+ <!-- Section 1: Upload / Detection -->
155
+ <div class="card section-upload">
156
  <div class="card-header">
157
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
158
+ <h3>Upload / Detection</h3>
159
  </div>
160
  <form id="form-detect">
161
  <div class="location-row">
 
241
  </form>
242
  </div>
243
 
244
+ <!-- Section 2: Result View -->
245
  <div class="card hidden" id="result-card">
246
  <div class="card-header">
247
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
248
+ <h3>Result View</h3>
249
  </div>
250
  <div class="result-stats" id="result-stats"></div>
251
 
252
+ <!-- Zoom controls + Before/After slider -->
253
+ <div class="zoom-slider-section">
254
+ <div class="zoom-controls">
255
+ <button type="button" class="btn btn-zoom" id="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
256
+ <span class="zoom-level" id="zoom-level">100%</span>
257
+ <button type="button" class="btn btn-zoom" id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
258
  </div>
259
+ <div class="zoom-wrapper" id="zoom-wrapper">
260
+ <div class="compare-slider compare-slider-zoomed" id="compare-slider">
261
+ <div class="compare-before">
262
+ <img id="compare-before-img" alt="Before" draggable="false" />
263
+ <span class="compare-label compare-label-left">Before</span>
264
+ </div>
265
+ <div class="compare-after" id="compare-after-clip">
266
+ <img id="compare-after-img" alt="Changes detected" draggable="false" />
267
+ <span class="compare-label compare-label-right">Changes</span>
268
+ </div>
269
+ <div class="compare-handle" id="compare-handle">
270
+ <div class="compare-handle-line"></div>
271
+ <div class="compare-handle-knob">
272
+ <svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 10l4-5v10z" fill="currentColor"/><path d="M14 10l-4-5v10z" fill="currentColor"/></svg>
273
+ </div>
274
+ <div class="compare-handle-line"></div>
275
+ </div>
276
+ <!-- Overlay for highlighting region on table row hover -->
277
+ <div class="region-highlight-overlay" id="region-highlight-overlay"></div>
278
  </div>
 
279
  </div>
280
  </div>
281
 
282
+ <!-- Tabular change summary: hover row highlights box on image -->
283
  <div class="regions-table-wrap">
284
  <table class="regions-table" id="regions-table">
285
  <thead>
 
287
  <th>#</th>
288
  <th>Change Type</th>
289
  <th>Sub-Type</th>
290
+ <th>Severity</th>
291
  <th>Confidence</th>
292
  <th>Area (px)</th>
293
+ <th>Coordinates</th>
294
  <th>Stories</th>
295
  <th>Height</th>
296
  <th>Stage</th>
 
297
  </tr>
298
  </thead>
299
  <tbody id="regions-tbody"></tbody>
 
301
  </div>
302
  </div>
303
 
304
+ <!-- Section 3: History View (tabular, click row to open result) -->
305
+ <div class="card mt-2 section-history">
306
  <div class="card-header">
307
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--grad-start)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
308
+ <h3>History View</h3>
309
+ </div>
310
+ <div class="history-table-wrap">
311
+ <table class="history-table" id="history-table">
312
+ <thead>
313
+ <tr>
314
+ <th>Timestamp</th>
315
+ <th>Before</th>
316
+ <th>After</th>
317
+ <th>Result</th>
318
+ <th>Regions</th>
319
+ <th>% Change</th>
320
+ <th>Actions</th>
321
+ </tr>
322
+ </thead>
323
+ <tbody id="history-tbody"></tbody>
324
+ </table>
325
  </div>
326
+ <div id="history-empty" class="history-empty hidden">No detection runs yet. Upload images above to get started.</div>
327
  </div>
328
 
329
  </section>
 
341
  </div>
342
  </div>
343
 
344
+ <script src="/static/js/app.js?v=16"></script>
345
  </body>
346
  </html>