Spaces:
Sleeping
Sleeping
Commit ·
b31590e
1
Parent(s): 19f4fba
History table view, zoom, severity markers, row hover highlight, before/after thumbnails
Browse files- app/detection_engine.py +64 -34
- app/main.py +72 -9
- app/models.py +3 -1
- static/css/style.css +110 -2
- static/js/app.js +156 -36
- templates/index.html +54 -25
app/detection_engine.py
CHANGED
|
@@ -418,11 +418,42 @@ def _clean_mask(mask, sensitivity=0.5):
|
|
| 418 |
|
| 419 |
|
| 420 |
# ---------------------------------------------------------------------------
|
| 421 |
-
# 9.
|
| 422 |
# ---------------------------------------------------------------------------
|
| 423 |
|
| 424 |
-
def
|
| 425 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 436 |
red_layer = np.zeros_like(img2, dtype=np.float32)
|
| 437 |
-
red_layer[:, :, 0] = 255
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 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 [
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 212 |
-
|
|
|
|
| 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
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
if
|
| 348 |
-
|
|
|
|
|
|
|
| 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="")
|
|
|
|
|
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
| 398 |
|
| 399 |
resetCompareSlider();
|
|
|
|
| 400 |
|
| 401 |
tbody.innerHTML = '';
|
| 402 |
-
(data.regions || []).slice(0, 50)
|
|
|
|
| 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
|
| 458 |
async function loadHistory() {
|
| 459 |
-
const
|
| 460 |
-
|
|
|
|
|
|
|
| 461 |
try {
|
| 462 |
const items = await api('GET', '/api/history');
|
| 463 |
if (!items || items.length === 0) {
|
| 464 |
-
|
|
|
|
| 465 |
return;
|
| 466 |
}
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
return `
|
| 470 |
-
<
|
| 471 |
-
<
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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 |
-
</
|
| 485 |
-
</
|
| 486 |
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
} catch (_) {
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 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 |
-
<!--
|
| 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>
|
| 159 |
</div>
|
| 160 |
<form id="form-detect">
|
| 161 |
<div class="location-row">
|
|
@@ -241,33 +241,45 @@
|
|
| 241 |
</form>
|
| 242 |
</div>
|
| 243 |
|
| 244 |
-
<!--
|
| 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>
|
| 249 |
</div>
|
| 250 |
<div class="result-stats" id="result-stats"></div>
|
| 251 |
|
| 252 |
-
<!--
|
| 253 |
-
<div class="
|
| 254 |
-
<div class="
|
| 255 |
-
<
|
| 256 |
-
<span class="
|
|
|
|
| 257 |
</div>
|
| 258 |
-
<div class="
|
| 259 |
-
<
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 298 |
</div>
|
| 299 |
|
| 300 |
</section>
|
|
@@ -312,6 +341,6 @@
|
|
| 312 |
</div>
|
| 313 |
</div>
|
| 314 |
|
| 315 |
-
<script src="/static/js/app.js?v=
|
| 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>
|