Spaces:
Sleeping
Sleeping
Commit Β·
d70361b
0
Parent(s):
Deploy satdetect-dev with Priyanka audit/exception logs.
Browse filesOrphan snapshot for Hugging Face 10 MiB limit. Includes dashboard, RBAC, and library load optimizations for online testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This view is limited to 50 files because it contains too many changes. Β See raw diff
- .claude/launch.json +11 -0
- .dockerignore +8 -0
- .env.example +159 -0
- .gitignore +44 -0
- .vscode/launch.json +15 -0
- AI_Change_Detection_Handover.md +280 -0
- ARCHITECTURE.md +387 -0
- DEPLOYMENT.md +227 -0
- DEV_SETUP.md +284 -0
- Dockerfile +55 -0
- README.md +133 -0
- WORKFLOW.md +371 -0
- app/__init__.py +1 -0
- app/auth.py +456 -0
- app/cd_models/__init__.py +0 -0
- app/cd_models/bit_cd.py +386 -0
- app/cd_models/change_model.py +211 -0
- app/cd_models/irmad.py +212 -0
- app/cd_models/kpca_features.py +163 -0
- app/cd_models/kpca_method.py +97 -0
- app/cd_models/model_utils.py +261 -0
- app/cd_models/weights/.gitkeep +1 -0
- app/database.py +65 -0
- app/dda/__init__.py +1 -0
- app/dda/admin_routes.py +95 -0
- app/dda/applog.py +130 -0
- app/dda/audit_routes.py +100 -0
- app/dda/auto_detect.py +614 -0
- app/dda/bootstrap.py +217 -0
- app/dda/change_type_map.py +46 -0
- app/dda/config.py +138 -0
- app/dda/dashboard_routes.py +343 -0
- app/dda/dda_auth.py +131 -0
- app/dda/dept_export.py +151 -0
- app/dda/detect_service.py +490 -0
- app/dda/exception_routes.py +132 -0
- app/dda/geo_regions.py +335 -0
- app/dda/geotiff_io.py +666 -0
- app/dda/job_progress.py +47 -0
- app/dda/job_runner.py +373 -0
- app/dda/jobs_routes.py +288 -0
- app/dda/library_routes.py +273 -0
- app/dda/local_library.py +202 -0
- app/dda/local_routes.py +485 -0
- app/dda/logs_routes.py +50 -0
- app/dda/map_tiles.py +418 -0
- app/dda/masters_routes.py +241 -0
- app/dda/models.py +137 -0
- app/dda/pair_align.py +273 -0
- app/dda/preflight.py +317 -0
.claude/launch.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "0.0.1",
|
| 3 |
+
"configurations": [
|
| 4 |
+
{
|
| 5 |
+
"name": "change-detection-dev",
|
| 6 |
+
"runtimeExecutable": "venv/Scripts/python.exe",
|
| 7 |
+
"runtimeArgs": ["run.py"],
|
| 8 |
+
"port": 8000
|
| 9 |
+
}
|
| 10 |
+
]
|
| 11 |
+
}
|
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
data/satellite_app.db
|
| 6 |
+
data/overlays/*
|
| 7 |
+
*.md
|
| 8 |
+
.env
|
.env.example
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and uncomment values as needed (optional for local dev).
|
| 2 |
+
# python run.py sets APP_MODE=dda automatically.
|
| 3 |
+
|
| 4 |
+
# APP_MODE=dda
|
| 5 |
+
# REQUIRED for stable login sessions (including in production): without this,
|
| 6 |
+
# a random secret is generated per process start, invalidating every existing
|
| 7 |
+
# session/cookie on each restart. Generate one with:
|
| 8 |
+
# python -c "import secrets; print(secrets.token_hex(32))"
|
| 9 |
+
# SECRET_KEY=change-me-to-a-long-random-string
|
| 10 |
+
|
| 11 |
+
# Database (default: SQLite in data/satellite_app.db)
|
| 12 |
+
# DATABASE_URL=sqlite:///./data/satellite_app.db
|
| 13 |
+
#
|
| 14 |
+
# MySQL (production for this project): install the driver first
|
| 15 |
+
# (pip install pymysql, already in requirements.txt), then set:
|
| 16 |
+
# DATABASE_URL=mysql+pymysql://user:password@host:3306/dbname
|
| 17 |
+
# URL-encode special characters in the password (e.g. @ -> %40).
|
| 18 |
+
#
|
| 19 |
+
# PostgreSQL also supported:
|
| 20 |
+
# DATABASE_URL=postgresql://user:pass@host/db
|
| 21 |
+
|
| 22 |
+
# Local image library folder (default: library_sources/)
|
| 23 |
+
# LOCAL_LIBRARY_ROOT=C:/path/to/your/images
|
| 24 |
+
|
| 25 |
+
# Image upload limit in MB (default 15360 = 15 GB)
|
| 26 |
+
# MAX_GEOTIFF_MB=15360
|
| 27 |
+
# MAX_IMAGE_MB=15360
|
| 28 |
+
|
| 29 |
+
# Automatic mode: identified same-area pairs can be queued on a cadence.
|
| 30 |
+
# The queue does NOT start when the app launches. On the Automatic tab, set
|
| 31 |
+
# interval (days) and run-at time (IST), then click Start. Manual mode is
|
| 32 |
+
# still click-to-run only. The local server must stay running while the
|
| 33 |
+
# schedule is armed. Set AUTO_DETECT_ENABLED=0 to hide/disable the feature.
|
| 34 |
+
# AUTO_DETECT_ENABLED=1
|
| 35 |
+
# AUTO_DETECT_INTERVAL_DAYS=10
|
| 36 |
+
# AUTO_DETECT_RUN_AT=02:00
|
| 37 |
+
|
| 38 |
+
# Max pixel dimension for detection (lower = less RAM, default 4096 local)
|
| 39 |
+
# DETECTION_MAX_SIDE=4096
|
| 40 |
+
|
| 41 |
+
# --- Accuracy controls (all optional; defaults preserve current behavior) ---
|
| 42 |
+
# Inference mode: downscaled | fullres_tiled | auto
|
| 43 |
+
# auto = fullres_tiled when before/after paths are GeoTIFF (RCA Critical #1)
|
| 44 |
+
# DETECTION_INFERENCE_MODE=auto
|
| 45 |
+
# Full-res cap (px) in fullres_tiled mode; 0 = native resolution
|
| 46 |
+
# DETECTION_FULLRES_MAX_SIDE=8192
|
| 47 |
+
# Native side (px) above which GeoTIFFs stream from disk windows (avoids OOM)
|
| 48 |
+
# Default lowered 8192β4096 (RCA Significant #5)
|
| 49 |
+
# DETECTION_WINDOWED_THRESHOLD=4096
|
| 50 |
+
# Soft RAM budget (MB) per in-memory array before switching to windowed streaming
|
| 51 |
+
# DETECTION_TILE_MEMORY_MB=1536
|
| 52 |
+
# Tile size / overlap for full-res scoring (overlap default 0.35 β RCA #6 seams)
|
| 53 |
+
# DETECTION_TILE_SIZE=512
|
| 54 |
+
# DETECTION_TILE_OVERLAP=0.35
|
| 55 |
+
# Skip SIFT registration on GPS-aligned GeoTIFF pairs: true|false|auto (RCA #4)
|
| 56 |
+
# DETECTION_SKIP_REGISTRATION_GEOTIFF=auto
|
| 57 |
+
# Local Delhi fine-tuned AdaptFormer (v3 frozen = Test F1 0.581 @ thr 0.2).
|
| 58 |
+
# Relative paths are resolved against the repo root (not the process CWD).
|
| 59 |
+
# ADAPTFORMER_WEIGHTS=models/adaptformer_delhi/v3_frozen
|
| 60 |
+
# Val-calibrated DL threshold (alias: DETECTION_DL_THRESHOLD). v3 = 0.2
|
| 61 |
+
# ADAPTFORMER_THRESHOLD=0.2
|
| 62 |
+
# DETECTION_DL_THRESHOLD=0.2
|
| 63 |
+
# Multi-scale DL fusion: off or a comma list, e.g. 0.5,1.0,1.5
|
| 64 |
+
# DETECTION_MULTISCALE=off
|
| 65 |
+
# Fusion strategy: smart_union | hysteresis | dl_only
|
| 66 |
+
# v3 ablation (runs/v3_app_ablation): dl_only @ 0.2 β Test F1 0.58;
|
| 67 |
+
# smart_union/hysteresis collapsed F1 to ~0.04 β prefer dl_only with v3.
|
| 68 |
+
# DETECTION_FUSION=dl_only
|
| 69 |
+
# TTA: off | hflip | full | auto. Ablation: hflip β same F1, ~2x slower on CPU.
|
| 70 |
+
# DETECTION_TTA=off
|
| 71 |
+
# Preprocessing toggles
|
| 72 |
+
# DETECTION_CLAHE=true
|
| 73 |
+
# DETECTION_HIST_MATCH=false
|
| 74 |
+
# DETECTION_SKIP_PREBLUR= # auto: on in fullres_tiled mode
|
| 75 |
+
# Border pixels zeroed in mask cleanup (auto: 4 fullres / 12 downscaled)
|
| 76 |
+
# DETECTION_BORDER_MARGIN=
|
| 77 |
+
# Tiles per GPU forward pass (1 = no batching)
|
| 78 |
+
# DETECTION_TILE_BATCH=1
|
| 79 |
+
# Save a downsampled probability-map PNG per run for debugging
|
| 80 |
+
# DETECTION_SAVE_PROB_MAP=false
|
| 81 |
+
# Skip the deep model entirely on tiles that are fully unchanged between before/
|
| 82 |
+
# after (worst-cell LAB distance below this value β biggest single cell diff,
|
| 83 |
+
# not an average, so a small real change is never diluted away and missed).
|
| 84 |
+
# Big speed win on large images where most of the scene is static background;
|
| 85 |
+
# 0/unset = disabled (default). Start conservative (e.g. 3-5).
|
| 86 |
+
# DETECTION_SKIP_UNCHANGED_THRESHOLD=0
|
| 87 |
+
|
| 88 |
+
# --- smart_union fusion calibration (Delhi accuracy sprint, Day 4-6) ---
|
| 89 |
+
# Base classical-score percentile floor for smart_union fusion. Tuned from
|
| 90 |
+
# 0.92 -> 0.90 against real Delhi Sentinel-2 imagery: +53% mean F1 on the
|
| 91 |
+
# 16-pair labeled eval set, verified to pass the full synthetic regression
|
| 92 |
+
# suite (incl. the mandatory car-FP gate) unchanged. Lower values (0.75-0.85)
|
| 93 |
+
# scored even higher on Delhi but were REJECTED β they cause false positives
|
| 94 |
+
# on the parked_cars synthetic case. See runs/calibration/best_params.json
|
| 95 |
+
# for the full decision record (winning + rejected configs, with reasons).
|
| 96 |
+
# DETECTION_CL_Q_BASE=0.90
|
| 97 |
+
# Base DL confidence floor for smart_union fusion (default 0.36). Tested
|
| 98 |
+
# down to 0.10 against Delhi imagery β no meaningful effect at full-set scale
|
| 99 |
+
# (the pretrained AdaptFormer model's near-zero output on this coarse-GSD,
|
| 100 |
+
# out-of-domain imagery isn't a thresholding problem; needs fine-tuning, not
|
| 101 |
+
# calibration). Left at default. See runs/calibration/best_params.json.
|
| 102 |
+
# DETECTION_DL_FLOOR_BASE=0.15
|
| 103 |
+
|
| 104 |
+
# --- Research-integration channels (KPCAMNet / IR-MAD / SRCDNet / BIT_CD) ---
|
| 105 |
+
# KPCA deep features (KPCAMNet): auto (default; drives Feature-Based method),
|
| 106 |
+
# on (also adds a channel to the classical score map), off
|
| 107 |
+
# DETECTION_KPCA=auto
|
| 108 |
+
# Analysis-resolution cap for KPCA feature extraction (CPU bound)
|
| 109 |
+
# DETECTION_KPCA_MAX_SIDE=768
|
| 110 |
+
# IR-MAD change statistic: auto (default) | on (adds classical channel) | off
|
| 111 |
+
# DETECTION_IRMAD=auto
|
| 112 |
+
# Regression radiometric normalization from IR-MAD no-change pixels
|
| 113 |
+
# (off by default: reduced F1 on the LEVIR-CD gate; useful for strong
|
| 114 |
+
# sensor/illumination mismatch between acquisitions)
|
| 115 |
+
# DETECTION_IRMAD_NORM=false
|
| 116 |
+
# Resample GeoTIFF pairs to a common (coarser) GSD before detection
|
| 117 |
+
# DETECTION_GSD_HARMONIZE=true
|
| 118 |
+
# Relative GSD difference that triggers harmonization/preflight warning
|
| 119 |
+
# DETECTION_GSD_TOLERANCE=0.15
|
| 120 |
+
# Preflight overlap gate: below this fraction of the after-image footprint,
|
| 121 |
+
# a pair is rejected as not suitable for change detection (hard fail)
|
| 122 |
+
# DETECTION_MIN_OVERLAP_HARD=0.02
|
| 123 |
+
# Below this fraction, overlap is weak but detection still runs (soft warning)
|
| 124 |
+
# DETECTION_MIN_OVERLAP_WARN=0.20
|
| 125 |
+
# Ensemble AdaptFormer with BIT_CD (requires weights, see below; default off)
|
| 126 |
+
# DETECTION_ENSEMBLE=off
|
| 127 |
+
# BIT_CD LEVIR-CD checkpoint path. Download best_ckpt.pt from the BIT_CD
|
| 128 |
+
# repo's release links (https://github.com/justchenhao/BIT_CD) and place it at
|
| 129 |
+
# app/cd_models/weights/bit_cd_levir.pth (raw training checkpoints with a
|
| 130 |
+
# model_G_state_dict key are accepted as-is).
|
| 131 |
+
# BIT_CD_WEIGHTS=app/cd_models/weights/bit_cd_levir.pth
|
| 132 |
+
|
| 133 |
+
# Public URL for report links in emails (default http://localhost:8000 locally)
|
| 134 |
+
# PUBLIC_BASE_URL=http://localhost:8000
|
| 135 |
+
|
| 136 |
+
# Email notifications (optional). Password reset still uses this backend.
|
| 137 |
+
# EMAIL_API_URL=https://emailservice.managemybusinessess.com/api/email/send
|
| 138 |
+
# Login SMS OTP: POST ToMobile/Message to SMS_API_URL (default: same host as
|
| 139 |
+
# EMAIL_API_URL with /api/sms/send). Or set SMS_PROVIDER=fast2sms + SMS_API_KEY.
|
| 140 |
+
# SMS_API_URL=
|
| 141 |
+
# SMS_PROVIDER=
|
| 142 |
+
# SMS_API_KEY=
|
| 143 |
+
# LOGIN_OTP_LOG=1
|
| 144 |
+
# SMTP_HOST=smtp.gmail.com
|
| 145 |
+
# SMTP_PORT=587
|
| 146 |
+
# SMTP_USER=
|
| 147 |
+
# SMTP_PASS=
|
| 148 |
+
|
| 149 |
+
# Hugging Face model cache directory
|
| 150 |
+
# HF_HOME=./.hf_cache
|
| 151 |
+
|
| 152 |
+
# Department export API (optional β FR-08)
|
| 153 |
+
# DEPT_API_URL=https://dept.example.gov/api/changes
|
| 154 |
+
# DEPT_API_KEY=your-api-key
|
| 155 |
+
|
| 156 |
+
# DDA admin account (optional β UAT / ops)
|
| 157 |
+
# DDA_ADMIN_EMAIL=admin@example.com
|
| 158 |
+
# DDA_ADMIN_PASSWORD=change-me
|
| 159 |
+
# DDA_TRAINING_EXPORT_KEY=secret-key-for-fp-export
|
.gitignore
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
data/satellite_app.db
|
| 5 |
+
data/overlays/*
|
| 6 |
+
!data/overlays/.gitkeep
|
| 7 |
+
data/library_cache/
|
| 8 |
+
# Local library images (too large for git β keep on disk only)
|
| 9 |
+
library_sources/**/*.tif
|
| 10 |
+
library_sources/**/*.tiff
|
| 11 |
+
library_sources/**/*.TIF
|
| 12 |
+
library_sources/**/*.TIFF
|
| 13 |
+
data/library_sources/**/*.tif
|
| 14 |
+
data/library_sources/**/*.tiff
|
| 15 |
+
data/library_sources/**/*.TIF
|
| 16 |
+
data/library_sources/**/*.TIFF
|
| 17 |
+
data/library_sources/**/*.png
|
| 18 |
+
data/library_sources/**/*.jpg
|
| 19 |
+
data/library_sources/**/*.jpeg
|
| 20 |
+
!library_sources/**/.gitkeep
|
| 21 |
+
.env
|
| 22 |
+
*.egg-info/
|
| 23 |
+
dist/
|
| 24 |
+
build/
|
| 25 |
+
venv/
|
| 26 |
+
# Generated eval/calibration outputs (masks, leaderboards, metrics)
|
| 27 |
+
runs/
|
| 28 |
+
# Large fine-tuned weights (export locally via scripts/export_adaptformer_delhi.py)
|
| 29 |
+
models/adaptformer_delhi/best/
|
| 30 |
+
models/adaptformer_delhi/*.pt
|
| 31 |
+
*.safetensors
|
| 32 |
+
# Local label backup from merge conflicts
|
| 33 |
+
docs/delhi_eval/labels/_local_backup_*/
|
| 34 |
+
# WHU reference dataset (real-data pipeline test set, not Delhi imagery β see docs/whu_reference/)
|
| 35 |
+
data/whu_reference/
|
| 36 |
+
data/large_geotiff_test/
|
| 37 |
+
data/large_geotiff_test.zip
|
| 38 |
+
# Third-party CD clones + DSIFN Drive downloads (hundreds of MB)
|
| 39 |
+
third_party/
|
| 40 |
+
models/dsifn_proxy/*.pt
|
| 41 |
+
# Building-footprint reference data (461MB geojson β exceeds GitHub's 100MB limit)
|
| 42 |
+
data/building_footprints/
|
| 43 |
+
# Priyanka synthetic CD triplets (before/after/mask PNGs β local training only)
|
| 44 |
+
data/synthetic_cd/
|
.vscode/launch.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
// Use IntelliSense to learn about possible attributes.
|
| 3 |
+
// Hover to view descriptions of existing attributes.
|
| 4 |
+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
| 5 |
+
"version": "0.2.0",
|
| 6 |
+
"configurations": [
|
| 7 |
+
{
|
| 8 |
+
"name": "PowerShell: Launch Current File",
|
| 9 |
+
"type": "PowerShell",
|
| 10 |
+
"request": "launch",
|
| 11 |
+
"script": "${file}",
|
| 12 |
+
"args": []
|
| 13 |
+
}
|
| 14 |
+
]
|
| 15 |
+
}
|
AI_Change_Detection_Handover.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Change Detection β Project Handover Document
|
| 2 |
+
|
| 3 |
+
**Prepared by:** Development Team
|
| 4 |
+
**Date:** February 7, 2026
|
| 5 |
+
**Version:** 2.0
|
| 6 |
+
**Live URL:** [Hugging Face Spaces β AI Change Detection](https://huggingface.co/spaces/coderuday21/satdetect)
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1. Executive Summary
|
| 11 |
+
|
| 12 |
+
AI Change Detection is a web-based application that automatically detects, classifies, and visualizes changes between two satellite or aerial images taken at different times. Users upload a "before" and "after" image, and the system identifies what changed on the ground β new buildings, deforestation, road construction, demolitions, water body changes, and more.
|
| 13 |
+
|
| 14 |
+
The application provides detailed reports including change statistics, object-level classification with sub-type breakdowns, 3D building analysis (estimated height and construction stage), and an interactive visual overlay highlighting all detected changes in red.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 2. Problem Statement
|
| 19 |
+
|
| 20 |
+
Manually comparing satellite imagery to identify ground-level changes is:
|
| 21 |
+
|
| 22 |
+
- **Time-consuming** β Analysts spend hours visually scanning large image pairs pixel by pixel.
|
| 23 |
+
- **Error-prone** β Human eyes miss subtle changes in vegetation health, gradual construction, or surface texture shifts.
|
| 24 |
+
- **Inconsistent** β Different analysts may classify the same change differently.
|
| 25 |
+
- **Unscalable** β As the volume of imagery grows (urban planning, environmental monitoring, disaster response), manual review cannot keep pace.
|
| 26 |
+
|
| 27 |
+
**AI Change Detection solves this** by automating the entire detection-to-classification pipeline, delivering consistent, quantified results in seconds.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 3. Target Users
|
| 32 |
+
|
| 33 |
+
| User Role | How They Use It |
|
| 34 |
+
|-----------|----------------|
|
| 35 |
+
| Urban planners | Track unauthorized construction, building expansion, and infrastructure changes |
|
| 36 |
+
| Environmental analysts | Monitor deforestation, vegetation health decline, and land-use changes |
|
| 37 |
+
| Disaster response teams | Assess structural damage after natural disasters by comparing pre/post imagery |
|
| 38 |
+
| Agricultural analysts | Detect crop changes, seasonal variation, and land conversion |
|
| 39 |
+
| Government agencies | Monitor compliance with zoning and environmental regulations |
|
| 40 |
+
| Construction firms | Track construction progress and stage classification over time |
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 4. Key Features
|
| 45 |
+
|
| 46 |
+
### 4.1 Multi-Method Change Detection
|
| 47 |
+
|
| 48 |
+
The system offers four detection methods, each suited to different scenarios:
|
| 49 |
+
|
| 50 |
+
| Method | Best For | Speed |
|
| 51 |
+
|--------|----------|-------|
|
| 52 |
+
| **Image Difference** | Quick scans where changes involve obvious color shifts (e.g., cleared forest) | Fastest |
|
| 53 |
+
| **Feature-Based** | Subtle, distributed changes across large areas (e.g., gradual land-use shift) | Fast |
|
| 54 |
+
| **AI-Based Deep Learning** | Highest accuracy β combines color, structure, texture, and edge analysis | Moderate |
|
| 55 |
+
| **Hybrid Approach** | Maximum coverage when unsure which type of changes to expect | Slowest |
|
| 56 |
+
|
| 57 |
+
Users select the method per run depending on their accuracy vs. speed needs.
|
| 58 |
+
|
| 59 |
+
### 4.2 Intelligent Pre-Processing
|
| 60 |
+
|
| 61 |
+
Before comparing images, the system can automatically:
|
| 62 |
+
|
| 63 |
+
- **Align images** (Image Registration) β Corrects for camera angle, zoom, and position differences between the two photos so the comparison is pixel-accurate.
|
| 64 |
+
- **Normalize lighting** (Radiometric Normalization) β Corrects for brightness/contrast differences caused by weather, time of day, or different cameras, preventing false positives from lighting alone.
|
| 65 |
+
|
| 66 |
+
Both options are user-toggleable per run.
|
| 67 |
+
|
| 68 |
+
### 4.3 Ground-Level Change Classification
|
| 69 |
+
|
| 70 |
+
Every detected change region is automatically classified into one of six categories:
|
| 71 |
+
|
| 72 |
+
| Category | What It Detects |
|
| 73 |
+
|----------|----------------|
|
| 74 |
+
| **New Construction / Building** | New structures appearing on previously empty land |
|
| 75 |
+
| **Demolition / Clearing** | Structures or vegetation that have been removed |
|
| 76 |
+
| **Vegetation Change** | Any change in plant cover β forests, crops, parks |
|
| 77 |
+
| **Water Body Change** | New, expanded, or reduced water features |
|
| 78 |
+
| **Road / Pavement Change** | New roads, widened paths, resurfaced areas |
|
| 79 |
+
| **Bare Land / Soil Change** | Exposed earth, grading, land preparation |
|
| 80 |
+
|
| 81 |
+
**Transient objects are automatically filtered out** β people, vehicles, animals, and shadows are excluded so only permanent ground-level changes are reported.
|
| 82 |
+
|
| 83 |
+
### 4.4 Detailed Sub-Classification
|
| 84 |
+
|
| 85 |
+
Each primary category is further broken down into specific sub-types by comparing the "before" and "after" regions:
|
| 86 |
+
|
| 87 |
+
**Vegetation Sub-Types:**
|
| 88 |
+
|
| 89 |
+
| Sub-Type | What It Means |
|
| 90 |
+
|----------|---------------|
|
| 91 |
+
| Deforestation / Tree Removal | Green area has been cleared β trees or vegetation removed |
|
| 92 |
+
| New Vegetation / Growth | Previously bare area now has plant cover |
|
| 93 |
+
| Crop / Agricultural Change | Farmland has changed crop type or land use pattern |
|
| 94 |
+
| Vegetation Health Decline | Plants are browning or showing signs of drought/disease |
|
| 95 |
+
| Seasonal Variation | Normal seasonal color shift, both periods still green |
|
| 96 |
+
|
| 97 |
+
**Structural Sub-Types:**
|
| 98 |
+
|
| 99 |
+
| Sub-Type | What It Means |
|
| 100 |
+
|----------|---------------|
|
| 101 |
+
| New Building | Structure appeared where none existed before |
|
| 102 |
+
| Building Expansion | Existing building has been extended or enlarged |
|
| 103 |
+
| Renovation / Modification | Building exists in both images but has changed appearance |
|
| 104 |
+
| Partial Demolition | Part of a structure has been removed |
|
| 105 |
+
| Full Demolition | Entire structure has been removed, area is now bare |
|
| 106 |
+
| Infrastructure Change | Non-building structures (bridges, towers, utilities) |
|
| 107 |
+
|
| 108 |
+
**Road Sub-Types:**
|
| 109 |
+
|
| 110 |
+
| Sub-Type | What It Means |
|
| 111 |
+
|----------|---------------|
|
| 112 |
+
| New Road / Pavement | Road appeared where none existed before |
|
| 113 |
+
| Road Widening | Existing road has been expanded |
|
| 114 |
+
| Road Resurfacing | Road surface has been repaved (different appearance, same width) |
|
| 115 |
+
| Road Deterioration | Road surface has degraded |
|
| 116 |
+
|
| 117 |
+
### 4.5 3D Building Analysis
|
| 118 |
+
|
| 119 |
+
For regions classified as buildings or construction, the system provides:
|
| 120 |
+
|
| 121 |
+
- **Estimated Stories** β Number of floors, calculated from shadow analysis and building footprint geometry.
|
| 122 |
+
- **Estimated Height** β Height in meters (based on 3m per story).
|
| 123 |
+
- **Construction Stage** β Classified as Foundation, Structural, Under Construction, or Complete based on visual features.
|
| 124 |
+
|
| 125 |
+
### 4.6 Interactive Visual Output
|
| 126 |
+
|
| 127 |
+
- **Red overlay** β All detected changes are highlighted in red on the "after" image.
|
| 128 |
+
- **Before/After slider** β A draggable comparison slider lets users visually inspect what changed by sliding between the original and the annotated result.
|
| 129 |
+
- **Annotated bounding boxes** β Each detected region gets a labeled bounding box showing its sub-type, stories, and construction stage.
|
| 130 |
+
|
| 131 |
+
### 4.7 User Accounts & History
|
| 132 |
+
|
| 133 |
+
- **Secure login system** β Email/password registration with encrypted password storage.
|
| 134 |
+
- **Detection history** β All past runs are saved with their results, overlay images, and metadata. Users can revisit or delete previous runs.
|
| 135 |
+
- **Password reset** β Users can reset their password from the login screen.
|
| 136 |
+
|
| 137 |
+
### 4.8 Responsive Design
|
| 138 |
+
|
| 139 |
+
The application is fully functional on desktop browsers and mobile devices, with an optimized layout for smaller screens.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## 5. Technology Stack
|
| 144 |
+
|
| 145 |
+
| Layer | Technology | Purpose |
|
| 146 |
+
|-------|-----------|---------|
|
| 147 |
+
| **Frontend** | HTML, CSS, JavaScript (Vanilla) | Single-page application β no framework dependencies |
|
| 148 |
+
| **Backend** | Python, FastAPI | REST API server handling authentication and detection |
|
| 149 |
+
| **Image Processing** | OpenCV, NumPy, Scikit-learn, Pillow | Core algorithms for detection, classification, and visualization |
|
| 150 |
+
| **Database** | SQLite (local), PostgreSQL (production-ready) | User accounts and detection run history |
|
| 151 |
+
| **Authentication** | JWT tokens, bcrypt password hashing | Secure stateless authentication |
|
| 152 |
+
| **Deployment** | Docker, Hugging Face Spaces | Containerized cloud deployment |
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## 6. Application Screens
|
| 157 |
+
|
| 158 |
+
### Login / Registration
|
| 159 |
+
- Clean login form with email and password
|
| 160 |
+
- Password visibility toggle (eye icon)
|
| 161 |
+
- "Forgot password" option
|
| 162 |
+
- Toggle between sign-in and registration
|
| 163 |
+
|
| 164 |
+
### Dashboard (Main Screen)
|
| 165 |
+
- **Upload Section** β Two drag-and-drop zones for before and after images with live previews
|
| 166 |
+
- **Configuration** β Method selector, run title, and optional pre-processing toggles
|
| 167 |
+
- **Results Section** β Appears after running detection:
|
| 168 |
+
- Statistics bar: change percentage, changed pixels, total pixels, region count
|
| 169 |
+
- Before/after comparison slider
|
| 170 |
+
- Detailed table of all detected regions with type, sub-type, confidence, area, building data, and coordinates
|
| 171 |
+
- **History Section** β List of all past runs with option to view overlay or delete
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## 7. Output Data Per Detection Run
|
| 176 |
+
|
| 177 |
+
Each run produces the following data:
|
| 178 |
+
|
| 179 |
+
| Field | Description |
|
| 180 |
+
|-------|-------------|
|
| 181 |
+
| Change Percentage | Proportion of the image area that changed |
|
| 182 |
+
| Changed Pixels | Total number of pixels detected as changed |
|
| 183 |
+
| Total Pixels | Total image area in pixels |
|
| 184 |
+
| Regions Count | Number of distinct change regions identified |
|
| 185 |
+
| **Per Region:** | |
|
| 186 |
+
| Change Type | Primary category (Construction, Vegetation, etc.) |
|
| 187 |
+
| Sub-Type | Detailed sub-classification (New Building, Deforestation, etc.) |
|
| 188 |
+
| Confidence | Classification confidence score (0β100%) |
|
| 189 |
+
| Area | Region size in pixels |
|
| 190 |
+
| Estimated Stories | Number of building floors (building regions only) |
|
| 191 |
+
| Estimated Height | Height in meters (building regions only) |
|
| 192 |
+
| Construction Stage | Foundation / Structural / Under Construction / Complete (building regions only) |
|
| 193 |
+
| Center Coordinates | Location of the region center in the image |
|
| 194 |
+
| Overlay Image | Annotated image with red highlights and bounding boxes |
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## 8. Deployment & Access
|
| 199 |
+
|
| 200 |
+
### Current Deployment
|
| 201 |
+
- **Platform:** Hugging Face Spaces (free tier)
|
| 202 |
+
- **URL:** https://huggingface.co/spaces/coderuday21/satdetect
|
| 203 |
+
- **Container:** Docker-based, auto-deploys on code push
|
| 204 |
+
- **Database:** SQLite (ephemeral β resets when the container restarts)
|
| 205 |
+
|
| 206 |
+
### Limitations of Current Deployment
|
| 207 |
+
| Limitation | Impact | Mitigation Path |
|
| 208 |
+
|-----------|--------|-----------------|
|
| 209 |
+
| Ephemeral database | User accounts and history are lost on container restart | Migrate to external PostgreSQL database |
|
| 210 |
+
| No email verification on password reset | Anyone who knows an email can reset the password | Implement token-based email verification flow |
|
| 211 |
+
| Single-worker server | Cannot handle high concurrent load | Scale to multiple workers with PostgreSQL |
|
| 212 |
+
| Free-tier resources | Limited CPU/memory, images capped at 2000px and 20MB | Upgrade to paid hosting for larger images |
|
| 213 |
+
|
| 214 |
+
### For Production Deployment
|
| 215 |
+
To move from the current demo to a production environment, the following would be needed:
|
| 216 |
+
1. External PostgreSQL database for persistent storage
|
| 217 |
+
2. Email service integration (SendGrid, AWS SES) for password reset verification
|
| 218 |
+
3. Paid cloud hosting (AWS, GCP, or Azure) for better performance
|
| 219 |
+
4. HTTPS with a custom domain
|
| 220 |
+
5. Environment-based secret key management
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
## 9. How Accuracy Works
|
| 225 |
+
|
| 226 |
+
The system does **not** use a pre-trained deep learning model (no neural network). Instead, it uses a **multi-signal fusion approach** β combining multiple independent analysis methods that each measure a different aspect of change:
|
| 227 |
+
|
| 228 |
+
1. **Color difference** β Measures how much the color changed at each pixel (in perceptually uniform color space).
|
| 229 |
+
2. **Structural similarity** β Measures whether the patterns and structures at each pixel changed.
|
| 230 |
+
3. **Texture analysis** β Measures whether the surface texture changed (smooth β rough, etc.).
|
| 231 |
+
4. **Edge detection** β Measures whether structural boundaries appeared or disappeared.
|
| 232 |
+
|
| 233 |
+
These four signals are weighted by their discriminative power and fused into a single change map. This approach is:
|
| 234 |
+
|
| 235 |
+
- **Transparent** β Every decision can be traced to specific visual features (no "black box").
|
| 236 |
+
- **Deterministic** β The same inputs always produce the same outputs.
|
| 237 |
+
- **No training data required** β Works on any satellite/aerial imagery without needing labeled datasets.
|
| 238 |
+
|
| 239 |
+
**Trade-off:** A trained deep learning model (e.g., U-Net, Siamese network) would likely achieve higher accuracy on specific datasets, but would require labeled training data and GPU resources. The current approach provides good accuracy out-of-the-box for general-purpose change detection.
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## 10. Future Enhancement Opportunities
|
| 244 |
+
|
| 245 |
+
| Enhancement | Business Value |
|
| 246 |
+
|-------------|---------------|
|
| 247 |
+
| Deep learning model integration (U-Net / Siamese CNN) | Significantly higher detection accuracy on trained domains |
|
| 248 |
+
| Batch processing / multi-image upload | Analyze time-series imagery for trend analysis |
|
| 249 |
+
| GIS integration (GeoTIFF, coordinate mapping) | Pin changes to real-world geographic coordinates |
|
| 250 |
+
| PDF/Excel report export | Downloadable reports for stakeholder distribution |
|
| 251 |
+
| Webhook / API integration | Automate detection as part of larger monitoring pipelines |
|
| 252 |
+
| Role-based access (admin, analyst, viewer) | Team collaboration with permission controls |
|
| 253 |
+
| Notification system | Alert stakeholders when significant changes are detected |
|
| 254 |
+
| Cloud-optimized image storage | Handle very large satellite tiles (10,000+ pixels) |
|
| 255 |
+
|
| 256 |
+
---
|
| 257 |
+
|
| 258 |
+
## 11. Repository Structure
|
| 259 |
+
|
| 260 |
+
```
|
| 261 |
+
change_detection_webapp/
|
| 262 |
+
βββ app/
|
| 263 |
+
β βββ main.py β API server and route definitions
|
| 264 |
+
β βββ auth.py β Authentication (JWT, password hashing)
|
| 265 |
+
β βββ models.py β Database table definitions
|
| 266 |
+
β βββ database.py β Database connection setup
|
| 267 |
+
β βββ detection_engine.py β All image processing and classification algorithms
|
| 268 |
+
βββ templates/
|
| 269 |
+
β βββ index.html β Frontend page structure
|
| 270 |
+
βββ static/
|
| 271 |
+
β βββ css/style.css β All visual styling
|
| 272 |
+
β βββ js/app.js β Client-side logic
|
| 273 |
+
βββ Dockerfile β Container build instructions
|
| 274 |
+
βββ requirements.txt β Python package dependencies
|
| 275 |
+
βββ ARCHITECTURE.md β Technical architecture reference (for developers)
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
---
|
| 279 |
+
|
| 280 |
+
*For technical implementation details, refer to `ARCHITECTURE.md` in the project repository.*
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Change Detection β Architecture & Component Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
AI Change Detection is a full-stack web application that detects, classifies, and visualizes changes between two satellite or aerial images (before and after). It uses multi-signal image processing β combining color analysis, structural similarity, texture descriptors, and edge detection β to identify ground-level changes such as new buildings, deforestation, road construction, and more.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## System Architecture
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
β Browser (SPA) β
|
| 14 |
+
β index.html + style.css + app.js β
|
| 15 |
+
β Login / Register / Upload / Results / History β
|
| 16 |
+
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
|
| 17 |
+
β REST API (JSON + multipart)
|
| 18 |
+
ββββββββββββββββββββββΌβββββββββββββββββββββββββββββ
|
| 19 |
+
β FastAPI Backend (main.py) β
|
| 20 |
+
β Auth routes Β· Detection route Β· History β
|
| 21 |
+
ββββββββββββ¬βββββββββββ¬ββββββββββββββββββββββββββββ€
|
| 22 |
+
β auth.py β models.pyβ detection_engine.py β
|
| 23 |
+
β JWT auth β ORM β Image processing pipeline β
|
| 24 |
+
ββββββββββββ΄βββββββββββ€ β
|
| 25 |
+
β database.py β SQLite (local) / MySQL β
|
| 26 |
+
βββββββββββββββββββββββ΄ββββββββββββββββββββββββββββ
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## File-by-File Component Breakdown
|
| 32 |
+
|
| 33 |
+
### 1. `app/main.py` β API Server & Route Definitions
|
| 34 |
+
|
| 35 |
+
**Role:** The central FastAPI application. Defines all HTTP endpoints, wires together authentication, database, and the detection engine.
|
| 36 |
+
|
| 37 |
+
**Use cases solved:**
|
| 38 |
+
- **User registration & login** (`POST /api/auth/register`, `POST /api/auth/login`) β Creates accounts and issues JWT tokens stored as HTTP-only cookies.
|
| 39 |
+
- **Session management** (`POST /api/auth/logout`, `GET /api/me`) β Clears auth cookies; returns the current authenticated user.
|
| 40 |
+
- **Password reset** (`POST /api/auth/reset-password`) β Allows users to reset their password by email (no email verification β documented limitation).
|
| 41 |
+
- **Change detection** (`POST /api/detect`) β Accepts two images (before/after) plus configuration options, runs the full detection pipeline, saves the overlay image to disk, stores run metadata in the database, and returns results with a base64-encoded overlay for immediate display.
|
| 42 |
+
- **History** (`GET /api/history`, `DELETE /api/history/{id}`) β Lists past detection runs for the logged-in user; allows deletion of individual runs and their overlay files.
|
| 43 |
+
- **Static file serving** β Mounts the `static/` directory for CSS/JS and serves `templates/index.html` as the SPA entry point.
|
| 44 |
+
- **Upload size limiting** β Rejects images larger than 20 MB to prevent out-of-memory crashes.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
### 2. `app/auth.py` β Authentication & Authorization
|
| 49 |
+
|
| 50 |
+
**Role:** Handles password hashing, JWT token creation/validation, and user lookup.
|
| 51 |
+
|
| 52 |
+
**Use cases solved:**
|
| 53 |
+
- **Secure password storage** β Uses bcrypt via `passlib` to hash passwords before storing them. Plaintext passwords never touch the database.
|
| 54 |
+
- **Stateless authentication** β Issues HS256 JWT tokens with a 7-day expiry. Tokens are sent as HTTP-only cookies (for browser requests) and also accepted as Bearer tokens in the Authorization header.
|
| 55 |
+
- **Multi-source token resolution** β `get_current_user()` checks three sources in order: Authorization header, HTTP cookie, then form field. This ensures authentication works for both API clients and browser multipart form submissions.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
### 3. `app/models.py` β Database Models (ORM)
|
| 60 |
+
|
| 61 |
+
**Role:** Defines the SQLAlchemy ORM models that map to database tables.
|
| 62 |
+
|
| 63 |
+
**Tables:**
|
| 64 |
+
- **`users`** β Stores account data: email, bcrypt-hashed password, full name, and creation timestamp. Each user has a one-to-many relationship with detection runs.
|
| 65 |
+
- **`detection_runs`** β Stores metadata for each detection run: title, method used, pixel statistics (total, changed, percentage), region count, path to the saved overlay image, and a JSON blob of all classified regions with their sub-types and 3D analysis data.
|
| 66 |
+
|
| 67 |
+
**Use case solved:** Persistent storage of user accounts and detection history so users can revisit past results.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
### 4. `app/database.py` β Database Connection & Session Management
|
| 72 |
+
|
| 73 |
+
**Role:** Configures the SQLAlchemy engine, session factory, and data directory paths.
|
| 74 |
+
|
| 75 |
+
**Use cases solved:**
|
| 76 |
+
- **Environment-aware storage** β Detects Hugging Face Spaces (via `SPACE_ID` env var) and uses a writable home directory; falls back to a local `data/` directory for development.
|
| 77 |
+
- **Database portability** β Defaults to SQLite for zero-config local development. Production uses MySQL via `DATABASE_URL` (`mysql+pymysql://...`); PostgreSQL is also supported (with automatic `postgres://` β `postgresql://` rewrite for SQLAlchemy 2.x compatibility). `pool_pre_ping` is enabled for any non-SQLite backend so stale/dropped remote connections are transparently recycled.
|
| 78 |
+
- **Session lifecycle** β The `get_db()` generator provides request-scoped database sessions with automatic cleanup.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
### 5. `app/detection_engine.py` β Image Processing & Classification Pipeline
|
| 83 |
+
|
| 84 |
+
This is the core of the application. It contains all image analysis algorithms organized into a sequential pipeline.
|
| 85 |
+
|
| 86 |
+
#### 5a. Pre-processing (`preprocess_image`)
|
| 87 |
+
|
| 88 |
+
**Use case:** Normalizes input images to a consistent format before analysis.
|
| 89 |
+
- Converts grayscale or RGBA images to RGB.
|
| 90 |
+
- Downscales images larger than 2000px on any side to prevent memory issues while preserving enough detail for analysis.
|
| 91 |
+
|
| 92 |
+
#### 5b. Image Registration (`register_images`)
|
| 93 |
+
|
| 94 |
+
**Use case:** Aligns the "after" image to the "before" image so pixel-level comparison is meaningful.
|
| 95 |
+
- Uses ORB feature detection to find matching keypoints between both images.
|
| 96 |
+
- Applies Lowe's ratio test to filter spurious matches.
|
| 97 |
+
- Computes a RANSAC homography to warp the after image into alignment.
|
| 98 |
+
- Rejects bad alignments via inlier ratio threshold and homography determinant check (catches degenerate transforms).
|
| 99 |
+
- **Why it matters:** Without alignment, even a slight camera angle difference would cause the entire image to appear "changed."
|
| 100 |
+
|
| 101 |
+
#### 5c. Radiometric Normalization (`normalize_radiometry`)
|
| 102 |
+
|
| 103 |
+
**Use case:** Corrects brightness and contrast differences between the two images caused by different lighting conditions, seasons, or sensors.
|
| 104 |
+
- Performs histogram matching in LAB color space (perceptually uniform) so the "after" image has the same color distribution as the "before."
|
| 105 |
+
- Applies CLAHE (Contrast Limited Adaptive Histogram Equalization) symmetrically to both images to enhance local contrast without introducing bias.
|
| 106 |
+
- **Why it matters:** A cloudy day vs. a sunny day would otherwise create massive false-positive "changes" everywhere.
|
| 107 |
+
|
| 108 |
+
#### 5d. SSIM Change Map (`compute_ssim_change_map`)
|
| 109 |
+
|
| 110 |
+
**Use case:** Measures structural dissimilarity between corresponding regions of both images.
|
| 111 |
+
- Computes per-pixel Structural Similarity Index (SSIM) using Gaussian-weighted windows.
|
| 112 |
+
- Outputs a dissimilarity map where 0 = identical and 1 = completely different.
|
| 113 |
+
- Clamps variance values to prevent numerical instability from floating-point rounding.
|
| 114 |
+
- **Why it matters:** SSIM captures structural patterns (edges, textures) that simple color difference would miss.
|
| 115 |
+
|
| 116 |
+
#### 5e. Texture Features (`compute_lbp`, `compute_texture_change`)
|
| 117 |
+
|
| 118 |
+
**Use case:** Detects changes in surface texture (e.g., smooth road vs. rough construction rubble).
|
| 119 |
+
- Computes Local Binary Patterns (LBP) β a texture descriptor that encodes the relationship between each pixel and its neighbors.
|
| 120 |
+
- The absolute difference between before/after LBP maps highlights regions where surface texture changed.
|
| 121 |
+
- **Why it matters:** Two regions can have similar colors but very different textures (e.g., grass vs. artificial turf, bare soil vs. concrete).
|
| 122 |
+
|
| 123 |
+
#### 5f. Edge Change Detection (`compute_edge_change`)
|
| 124 |
+
|
| 125 |
+
**Use case:** Identifies new or removed structural boundaries.
|
| 126 |
+
- Uses Canny edge detection with adaptive thresholds based on median intensity.
|
| 127 |
+
- Dilates edges so nearby edges match (tolerates minor misalignment).
|
| 128 |
+
- Computes the absolute difference to find edges present in one image but not the other.
|
| 129 |
+
- **Why it matters:** New buildings, roads, and demolished structures introduce or remove strong edges.
|
| 130 |
+
|
| 131 |
+
#### 5g. Detection Methods (4 options)
|
| 132 |
+
|
| 133 |
+
| Method | Description | Best For |
|
| 134 |
+
|--------|-------------|----------|
|
| 135 |
+
| **Image Difference** | LAB color space Delta-E difference with Otsu thresholding | Fast, simple comparisons with clear color changes |
|
| 136 |
+
| **Feature-Based** | Multi-space (LAB + HSV) feature clustering via KMeans | Subtle, distributed changes across large areas |
|
| 137 |
+
| **AI-Based Deep Learning** | Multi-signal fusion (color + SSIM + texture + edge) with entropy-weighted adaptive combination | Highest accuracy; catches both color and structural changes |
|
| 138 |
+
| **Hybrid Approach** | Weighted blend of all three methods (20% difference + 30% feature + 50% AI) | Maximum coverage when unsure which method is best |
|
| 139 |
+
|
| 140 |
+
**Image Difference** (`image_difference_method`):
|
| 141 |
+
- Converts to LAB and computes a weighted Delta-E (CIE76) difference per pixel.
|
| 142 |
+
- Applies Otsu's automatic threshold to separate "changed" from "unchanged."
|
| 143 |
+
- Fast but only catches color changes β misses structural changes where color stays similar.
|
| 144 |
+
|
| 145 |
+
**Feature-Based** (`feature_based_method`):
|
| 146 |
+
- Extracts LAB and HSV difference features per pixel.
|
| 147 |
+
- Downsamples to 250K pixels for KMeans clustering (4 clusters), then maps labels back to full resolution.
|
| 148 |
+
- The cluster with the highest mean feature magnitude is labeled "change."
|
| 149 |
+
- Good at finding spatially distributed or gradual changes.
|
| 150 |
+
|
| 151 |
+
**AI-Based Deep Learning** (`ai_deep_learning_method`):
|
| 152 |
+
- Fuses four independent signals: multi-scale LAB color difference, SSIM structural dissimilarity, LBP texture change, and Canny edge change.
|
| 153 |
+
- Weights each channel by its information entropy (channels with more discriminative power get higher weight).
|
| 154 |
+
- Applies Otsu thresholding followed by bilateral filtering for edge-preserving smoothing.
|
| 155 |
+
- The most accurate method β captures color, structure, texture, and edge changes simultaneously.
|
| 156 |
+
|
| 157 |
+
**Hybrid** (`hybrid_method`):
|
| 158 |
+
- Runs all three methods and combines their masks with fixed weights.
|
| 159 |
+
- AI method gets the most weight (50%) as it's most reliable.
|
| 160 |
+
|
| 161 |
+
#### 5h. Post-processing (`_clean_mask`)
|
| 162 |
+
|
| 163 |
+
**Use case:** Cleans up raw change masks to remove noise and fill gaps.
|
| 164 |
+
- Morphological closing (fills small gaps inside detected regions).
|
| 165 |
+
- Morphological opening (removes small noise speckles).
|
| 166 |
+
- Contour-based hole filling (ensures detected regions are solid).
|
| 167 |
+
- Sensitivity parameter controls the aggressiveness of gap-filling.
|
| 168 |
+
|
| 169 |
+
#### 5i. Visualization (`visualize_changes`)
|
| 170 |
+
|
| 171 |
+
**Use case:** Creates the final overlay image showing detected changes.
|
| 172 |
+
- Draws a semi-transparent red overlay on all changed pixels in the "after" image.
|
| 173 |
+
- Draws white bounding boxes around each classified region.
|
| 174 |
+
- Annotates regions with labels showing sub-type, building stories, and construction stage.
|
| 175 |
+
|
| 176 |
+
#### 5j. Object Classification (`classify_object_type`, `classify_with_ensemble`)
|
| 177 |
+
|
| 178 |
+
**Use case:** Determines what kind of ground-level change each detected region represents.
|
| 179 |
+
|
| 180 |
+
**Feature extraction** (`extract_advanced_features`):
|
| 181 |
+
- Extracts 17 features per region: color stats (RGB, HSV, LAB means/stds), vegetation index (NDVI), texture (LBP variance, standard deviation), edges (density, orientation entropy), GLCM-like contrast, shape ratios, and color ratios.
|
| 182 |
+
|
| 183 |
+
**Transient object filtering** (`_is_transient_object`):
|
| 184 |
+
- Filters out non-permanent changes: people, vehicles, animals, shadows.
|
| 185 |
+
- Uses area, aspect ratio, edge density, and texture variance thresholds.
|
| 186 |
+
|
| 187 |
+
**Classification categories:**
|
| 188 |
+
| Category | Key Indicators |
|
| 189 |
+
|----------|---------------|
|
| 190 |
+
| New Construction/Building | Low orientation entropy, compact shape, moderate edges, low saturation |
|
| 191 |
+
| Demolition/Clearing | High texture variance, high entropy, bright, low vegetation |
|
| 192 |
+
| Vegetation Change | High NDVI, green ratio, medium texture, high saturation |
|
| 193 |
+
| Water Body Change | High blue ratio, low texture, smooth surface, specific hue range |
|
| 194 |
+
| Road/Pavement Change | Elongated shape, homogeneous color, low saturation, low entropy |
|
| 195 |
+
| Bare Land/Soil Change | High red ratio, low NDVI, low texture, earth-tone hue |
|
| 196 |
+
|
| 197 |
+
**Ensemble voting** (`classify_with_ensemble`):
|
| 198 |
+
- Classifies the full region plus 5 sub-regions (quadrants + center).
|
| 199 |
+
- Combines votes weighted by confidence.
|
| 200 |
+
- Boosts confidence when β₯60% of sub-regions agree.
|
| 201 |
+
- Only excludes a region as transient if a majority of sub-regions are flagged.
|
| 202 |
+
|
| 203 |
+
#### 5k. Vegetation Sub-Classification (`classify_vegetation_subtype`)
|
| 204 |
+
|
| 205 |
+
**Use case:** Provides detailed breakdown of vegetation changes by comparing the before and after region crops.
|
| 206 |
+
|
| 207 |
+
| Sub-Type | Detection Logic |
|
| 208 |
+
|----------|----------------|
|
| 209 |
+
| Deforestation/Tree Removal | Before was green (high NDVI), after is bare (NDVI dropped, brightness increased) |
|
| 210 |
+
| New Vegetation/Growth | Before was bare, after is green (NDVI increased, saturation up) |
|
| 211 |
+
| Crop/Agricultural Change | Regular texture patterns in either image, moderate NDVI shift, large area |
|
| 212 |
+
| Vegetation Health Decline | Both images green but NDVI and saturation slightly decreased (browning/drought) |
|
| 213 |
+
| Seasonal Variation | Mild color/texture shift, both images still green |
|
| 214 |
+
|
| 215 |
+
#### 5l. Structural Sub-Classification (`classify_structural_subtype`)
|
| 216 |
+
|
| 217 |
+
**Use case:** Provides detailed breakdown of structural and road changes by comparing before/after structure presence, edge density, and texture.
|
| 218 |
+
|
| 219 |
+
| Sub-Type | Detection Logic |
|
| 220 |
+
|----------|----------------|
|
| 221 |
+
| New Building | No structure before, structure detected after |
|
| 222 |
+
| Building Expansion | Structure in both images, but after has higher edge density |
|
| 223 |
+
| Renovation/Modification | Structure in both, similar density but different appearance (brightness/saturation shift) |
|
| 224 |
+
| Partial Demolition | Structure before, reduced edges after |
|
| 225 |
+
| Full Demolition | Structure before, bare/empty after |
|
| 226 |
+
| Infrastructure Change | Elongated shape with high geometric regularity |
|
| 227 |
+
| New Road/Pavement | No structure before, structure after (for road-type regions) |
|
| 228 |
+
| Road Widening | Structure in both, edge density increased |
|
| 229 |
+
| Road Resurfacing | Structure in both, similar edges but brightness changed |
|
| 230 |
+
| Road Deterioration | Texture increased, edges decreased (surface degradation) |
|
| 231 |
+
|
| 232 |
+
#### 5m. 3D Building Analysis
|
| 233 |
+
|
| 234 |
+
**Use case:** Estimates building height/stories and classifies construction stage for building-type regions.
|
| 235 |
+
|
| 236 |
+
**Shadow-based height estimation** (`estimate_building_height`):
|
| 237 |
+
- Detects new shadow pixels adjacent to the building by comparing brightness in the expanded bounding box area between before and after images.
|
| 238 |
+
- Measures shadow length as the maximum spatial extent of the shadow cluster.
|
| 239 |
+
- Combines shadow ratio, footprint geometry (compact = likely taller), and texture regularity to estimate story count.
|
| 240 |
+
- Assumes 3 meters per story.
|
| 241 |
+
|
| 242 |
+
**Construction stage classification** (`classify_construction_stage`):
|
| 243 |
+
| Stage | Visual Indicators |
|
| 244 |
+
|-------|------------------|
|
| 245 |
+
| Foundation | Low texture, low edges, homogeneous, soil/concrete colors |
|
| 246 |
+
| Structural | High edges, geometric regularity (low entropy), high GLCM contrast |
|
| 247 |
+
| Under Construction | Mixed materials, irregular texture, medium edge density |
|
| 248 |
+
| Complete | Uniform roof, clean edges, low entropy, consistent color |
|
| 249 |
+
|
| 250 |
+
---
|
| 251 |
+
|
| 252 |
+
### 6. `templates/index.html` β Frontend Structure
|
| 253 |
+
|
| 254 |
+
**Role:** Single-page application (SPA) with four views managed by JavaScript.
|
| 255 |
+
|
| 256 |
+
**Views:**
|
| 257 |
+
- **Login** β Email/password form with password visibility toggle and "Forgot password" link.
|
| 258 |
+
- **Register** β Account creation form with name, email, and password.
|
| 259 |
+
- **Forgot Password** β Email + new password form (simplified reset without email verification).
|
| 260 |
+
- **Dashboard** β The main workspace containing:
|
| 261 |
+
- **Upload zone** β Drag-and-drop or click-to-browse for before/after images with live previews.
|
| 262 |
+
- **Detection options** β Method selector, title input, image registration and radiometric normalization toggles.
|
| 263 |
+
- **Results panel** β Statistics boxes (change %, changed pixels, total pixels, region count), before/after comparison slider, and a detailed regions table with columns for change type, sub-type, confidence, area, stories, height, construction stage, and center coordinates.
|
| 264 |
+
- **History** β List of past detection runs with view and delete actions.
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
### 7. `static/css/style.css` β Visual Design
|
| 269 |
+
|
| 270 |
+
**Role:** Complete styling for the application using CSS custom properties and a gradient theme.
|
| 271 |
+
|
| 272 |
+
**Key design decisions:**
|
| 273 |
+
- **Color theme** β Linear gradient from `#2e33c5` (blue) to `#cf2040` (red) applied to the navbar, footer, buttons, and accent elements. White background for content areas.
|
| 274 |
+
- **Sticky navbar** β Full-viewport-width gradient bar that stays fixed at the top during scrolling.
|
| 275 |
+
- **Mobile responsive** β Grid layouts collapse to single columns on screens under 768px. Font sizes, padding, and table layouts adapt to small screens.
|
| 276 |
+
- **Glassmorphism dropdown** β User account menu uses a gradient background with subtle shadow for the avatar popup.
|
| 277 |
+
- **Compare slider** β Custom CSS for the before/after image comparison handle with gradient accents.
|
| 278 |
+
- **Stat boxes** β Responsive font sizing with `clamp()` and compact number formatting to prevent overflow.
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
### 8. `static/js/app.js` β Client-Side Logic
|
| 283 |
+
|
| 284 |
+
**Role:** All browser-side interactivity β authentication flow, file uploads, API communication, result rendering, and UI state management.
|
| 285 |
+
|
| 286 |
+
**Key responsibilities:**
|
| 287 |
+
- **Authentication** β Login, register, forgot-password form handlers that call the API and store JWT tokens in localStorage. Automatic session check on page load (`init()`).
|
| 288 |
+
- **File upload** β Drag-and-drop and click-to-browse handlers with live image previews.
|
| 289 |
+
- **Detection submission** β Builds a FormData payload with images and settings, sends to `/api/detect`, and renders results.
|
| 290 |
+
- **Result rendering** β Populates stat boxes with compact-formatted numbers, fills the regions table, sets up the before/after comparison slider, and scrolls to results.
|
| 291 |
+
- **History management** β Loads past runs, renders them with view/delete actions, and handles deletion with an animated confirmation modal.
|
| 292 |
+
- **UI utilities** β View switching, error/success alerts, avatar dropdown toggle, password visibility toggle, and HTML escaping.
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
### 9. `Dockerfile` β Container Configuration
|
| 297 |
+
|
| 298 |
+
**Role:** Defines the Docker image for deployment on Hugging Face Spaces.
|
| 299 |
+
|
| 300 |
+
**Key aspects:**
|
| 301 |
+
- Base image: `python:3.11-slim` with OpenCV system dependencies (`libgl1`, `libglib2.0-0`, etc.).
|
| 302 |
+
- Non-root user (`appuser`) as required by Hugging Face Spaces security policy.
|
| 303 |
+
- Runs Gunicorn with a single Uvicorn worker on port 7860 (HF Spaces default) with 120-second timeout for long detection runs.
|
| 304 |
+
- Single worker prevents SQLite write-lock race conditions.
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
### 10. `requirements.txt` β Python Dependencies
|
| 309 |
+
|
| 310 |
+
| Package | Purpose |
|
| 311 |
+
|---------|---------|
|
| 312 |
+
| `fastapi` | Web framework for the REST API |
|
| 313 |
+
| `uvicorn` | ASGI server (production via Gunicorn) |
|
| 314 |
+
| `gunicorn` | Process manager for production deployment |
|
| 315 |
+
| `python-multipart` | Handles multipart/form-data file uploads |
|
| 316 |
+
| `sqlalchemy` | ORM for database operations |
|
| 317 |
+
| `pymysql` | MySQL driver (production database) |
|
| 318 |
+
| `python-jose` | JWT token encoding/decoding |
|
| 319 |
+
| `passlib` + `bcrypt` | Password hashing (bcrypt pinned to 4.0.1 for compatibility) |
|
| 320 |
+
| `pillow` | Image loading and format conversion |
|
| 321 |
+
| `numpy` | Numerical array operations for image processing |
|
| 322 |
+
| `opencv-python-headless` | Core image processing (registration, morphology, edge detection, color conversion) |
|
| 323 |
+
| `scikit-learn` | KMeans clustering for feature-based detection; StandardScaler for normalization |
|
| 324 |
+
| `scipy` | Scientific computing utilities |
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## Data Flow: End-to-End Detection Run
|
| 329 |
+
|
| 330 |
+
```
|
| 331 |
+
1. User uploads before + after images via browser
|
| 332 |
+
β
|
| 333 |
+
2. POST /api/detect (multipart form with images + settings)
|
| 334 |
+
β
|
| 335 |
+
3. Authentication check (JWT from cookie/header/form)
|
| 336 |
+
β
|
| 337 |
+
4. Pre-processing
|
| 338 |
+
βββ Convert to RGB, limit to 2000px
|
| 339 |
+
β
|
| 340 |
+
5. Image Registration (optional)
|
| 341 |
+
βββ ORB keypoints β ratio test β RANSAC homography β warp
|
| 342 |
+
β
|
| 343 |
+
6. Radiometric Normalization (optional)
|
| 344 |
+
βββ LAB histogram matching + symmetric CLAHE
|
| 345 |
+
β
|
| 346 |
+
7. Change Detection (selected method)
|
| 347 |
+
βββ Image Difference: LAB Delta-E + Otsu
|
| 348 |
+
βββ Feature-Based: LAB/HSV features + KMeans clustering
|
| 349 |
+
βββ AI-Based: Multi-signal fusion (color + SSIM + texture + edge)
|
| 350 |
+
βββ Hybrid: Weighted combination of all three
|
| 351 |
+
β
|
| 352 |
+
8. Post-processing
|
| 353 |
+
βββ Morphological closing/opening + hole filling
|
| 354 |
+
β
|
| 355 |
+
9. Region Analysis
|
| 356 |
+
βββ Connected components β bounding boxes
|
| 357 |
+
βββ Feature extraction (17 features per region)
|
| 358 |
+
βββ Transient object filtering (remove people, cars, etc.)
|
| 359 |
+
βββ Primary classification (6 ground-level categories)
|
| 360 |
+
βββ Ensemble voting (full region + 5 sub-regions)
|
| 361 |
+
β
|
| 362 |
+
10. Sub-Classification
|
| 363 |
+
βββ Vegetation: Deforestation / Growth / Crop / Health / Seasonal
|
| 364 |
+
βββ Structural: New / Expansion / Renovation / Partial Demo / Full Demo / Infrastructure
|
| 365 |
+
βββ Road: New / Widening / Resurfacing / Deterioration
|
| 366 |
+
β
|
| 367 |
+
11. 3D Building Analysis (for construction/demolition regions)
|
| 368 |
+
βββ Shadow detection β height/stories estimation
|
| 369 |
+
βββ Construction stage classification
|
| 370 |
+
β
|
| 371 |
+
12. Visualization
|
| 372 |
+
βββ Red overlay on changed pixels
|
| 373 |
+
βββ Bounding boxes + annotation labels
|
| 374 |
+
β
|
| 375 |
+
13. Response
|
| 376 |
+
βββ Save overlay to disk + database record
|
| 377 |
+
βββ Return JSON: stats, regions, base64 overlay image
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## Deployment
|
| 383 |
+
|
| 384 |
+
- **Local development:** Run `uvicorn app.main:app --reload` from the project root. Uses `DATABASE_URL` if set (MySQL for this project), otherwise SQLite in `data/satellite_app.db`.
|
| 385 |
+
- **Hugging Face Spaces:** Push to the linked HF repo. The Dockerfile builds and deploys automatically. Falls back to SQLite at `/home/appuser/data/` (ephemeral β resets on container restart) unless `DATABASE_URL` is set.
|
| 386 |
+
- **Production with MySQL:** Set `DATABASE_URL` to a `mysql+pymysql://user:pass@host:3306/dbname` connection string (`pymysql` driver is in `requirements.txt`). Set `SECRET_KEY` to a strong random value. On Render, set `DATABASE_URL` manually in the dashboard (Environment tab) β it's `sync: false` in `render.yaml`, not committed to git.
|
| 387 |
+
- **Production with PostgreSQL** (also supported): same pattern with a `postgresql://` URL β requires adding `psycopg2-binary` to `requirements.txt` first (not installed by default).
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment β Two Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
This project uses **two separate Hugging Face Spaces** so the live app stays stable while you build changes for a new client.
|
| 4 |
+
|
| 5 |
+
| Environment | Space | Git remote | Branch | URL |
|
| 6 |
+
|-------------|-------|------------|--------|-----|
|
| 7 |
+
| **Production** (current clients) | `coderuday21/satdetect` | `hf` | `production` | https://huggingface.co/spaces/coderuday21/satdetect |
|
| 8 |
+
| **Development** (new client work) | `coderuday21/satdetect-dev` | `hf-dev` | `master` | https://huggingface.co/spaces/coderuday21/satdetect-dev |
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## One-time setup: create the dev Space
|
| 13 |
+
|
| 14 |
+
1. Open **https://huggingface.co/new-space**
|
| 15 |
+
2. Set:
|
| 16 |
+
- **Owner:** `coderuday21`
|
| 17 |
+
- **Space name:** `satdetect-dev`
|
| 18 |
+
- **SDK:** Docker
|
| 19 |
+
- **Visibility:** Public (or Private if you prefer)
|
| 20 |
+
3. Click **Create Space**.
|
| 21 |
+
|
| 22 |
+
Then add the dev remote (once):
|
| 23 |
+
|
| 24 |
+
```powershell
|
| 25 |
+
cd change_detection_webapp
|
| 26 |
+
git remote add hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
If `hf-dev` already exists, update it:
|
| 30 |
+
|
| 31 |
+
```powershell
|
| 32 |
+
git remote set-url hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Push the current development code to the new Space:
|
| 36 |
+
|
| 37 |
+
```powershell
|
| 38 |
+
git push hf-dev master:main --force
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
> **First push only:** Hugging Face creates a starter README commit when you create the Space. Use `--force` once to replace it with your app. Later pushes can omit `--force`.
|
| 42 |
+
|
| 43 |
+
Or run the helper script:
|
| 44 |
+
|
| 45 |
+
```powershell
|
| 46 |
+
.\scripts\push_hf_dev.ps1
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Branch strategy
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
production βββΊ hf (satdetect) β live app (no login; direct to detection)
|
| 55 |
+
master βββΊ hf-dev (satdetect-dev) β new client experiments and upcoming changes
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
- **`production`** β tracks the stable live release (currently same as `master`, no login).
|
| 59 |
+
- **`master`** β active development; push to the dev Space for testing before promoting to production.
|
| 60 |
+
|
| 61 |
+
Work on new client features on `master`. When ready for the live app, merge into `production` and push to `hf`.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Deploy commands
|
| 66 |
+
|
| 67 |
+
### Production (live app β no login)
|
| 68 |
+
|
| 69 |
+
```powershell
|
| 70 |
+
git checkout production
|
| 71 |
+
git merge master
|
| 72 |
+
git push hf production:main
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Or push `master` directly to production:
|
| 76 |
+
|
| 77 |
+
```powershell
|
| 78 |
+
git push hf master:main
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
Helper:
|
| 82 |
+
|
| 83 |
+
```powershell
|
| 84 |
+
.\scripts\push_hf_production.ps1
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Development (new client Space)
|
| 88 |
+
|
| 89 |
+
After every change you want on the dev Space:
|
| 90 |
+
|
| 91 |
+
```powershell
|
| 92 |
+
git checkout master
|
| 93 |
+
git push hf-dev master:main
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Helper:
|
| 97 |
+
|
| 98 |
+
```powershell
|
| 99 |
+
.\scripts\push_hf_dev.ps1
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## GitHub mirror
|
| 105 |
+
|
| 106 |
+
**Repo:** https://github.com/Uday-at-Vedang/DDA.ChangeDetection
|
| 107 |
+
**Default branch on GitHub:** `main`
|
| 108 |
+
|
| 109 |
+
Push the full app from local `master`:
|
| 110 |
+
|
| 111 |
+
```powershell
|
| 112 |
+
git push origin master:main
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Push the production branch too:
|
| 116 |
+
|
| 117 |
+
```powershell
|
| 118 |
+
git push origin production:main-production
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Set upstream (once, after first push):
|
| 122 |
+
|
| 123 |
+
```powershell
|
| 124 |
+
git branch --set-upstream-to=origin/main master
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## Environment variables (both Spaces)
|
| 130 |
+
|
| 131 |
+
Set these in each Spaceβs **Settings β Repository secrets / Variables** if needed:
|
| 132 |
+
|
| 133 |
+
| Variable | Purpose |
|
| 134 |
+
|----------|---------|
|
| 135 |
+
| `APP_MODE` | Set to `dda` on **satdetect-dev** only (enables Vedangsoft library UI) |
|
| 136 |
+
| `STORAGE_ROOT` | Tree library root directory (default: `data/library_sources/`) |
|
| 137 |
+
| `LOCAL_LIBRARY_ROOT` | Alias for storage root override |
|
| 138 |
+
| `MAX_GEOTIFF_MB` | Library GeoTIFF upload cap (default **5120** = 5 GB on dev) |
|
| 139 |
+
| `MAX_IMAGE_MB` | PNG/JPEG library cap (default 50 MB) |
|
| 140 |
+
| `SECRET_KEY` | Optional legacy JWT setting (login disabled) |
|
| 141 |
+
| `DATABASE_URL` | MySQL (production) instead of SQLite β `mysql+pymysql://user:pass@host:3306/db`; PostgreSQL also supported |
|
| 142 |
+
| `SMTP_USER` / `SMTP_PASS` | Email notifications via Gmail SMTP |
|
| 143 |
+
| `EMAIL_API_URL` | Custom email API (default in code) |
|
| 144 |
+
| `DEPT_API_URL` / `DEPT_API_KEY` | Departmental submit API (optional) |
|
| 145 |
+
| `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` | Seed admin user on dev (role: admin) |
|
| 146 |
+
| `DDA_TRAINING_EXPORT_KEY` | Header `X-DDA-Training-Key` for false-positive export |
|
| 147 |
+
|
| 148 |
+
### Detection accuracy controls (optional, dev-first)
|
| 149 |
+
|
| 150 |
+
All default to the previous behavior, so leaving them unset changes nothing.
|
| 151 |
+
|
| 152 |
+
| Variable | Purpose |
|
| 153 |
+
|----------|---------|
|
| 154 |
+
| `DETECTION_MAX_SIDE` | Downscaled-path pixel cap (default 4096 local / 2048 hosted) |
|
| 155 |
+
| `DETECTION_INFERENCE_MODE` | `downscaled` (default) or `fullres_tiled` (native detail) |
|
| 156 |
+
| `DETECTION_FULLRES_MAX_SIDE` | Full-res cap in fullres mode; 0 = native (default 8192) |
|
| 157 |
+
| `DETECTION_WINDOWED_THRESHOLD` | Native side above which GeoTIFFs stream from disk windows (default 8192) |
|
| 158 |
+
| `DETECTION_TILE_MEMORY_MB` | RAM budget per array before switching to windowed streaming (default 1536) |
|
| 159 |
+
| `DETECTION_TILE_SIZE` / `DETECTION_TILE_OVERLAP` | Full-res tile geometry (default 512 / 0.25) |
|
| 160 |
+
| `DETECTION_MULTISCALE` | `off` or scale list e.g. `0.5,1.0,1.5` (max-fused for recall) |
|
| 161 |
+
| `DETECTION_FUSION` | `smart_union` (default) or `hysteresis` |
|
| 162 |
+
| `DETECTION_CLAHE` / `DETECTION_HIST_MATCH` | Preprocessing toggles (CLAHE on, hist-match off) |
|
| 163 |
+
| `DETECTION_SKIP_PREBLUR` | Skip denoise (auto-on in fullres mode) |
|
| 164 |
+
| `DETECTION_BORDER_MARGIN` | Mask border zeroing (auto: 4 fullres / 12 downscaled) |
|
| 165 |
+
| `DETECTION_TILE_BATCH` | Tiles per GPU forward pass (default 1) |
|
| 166 |
+
| `DETECTION_SAVE_PROB_MAP` | Save a per-run probability PNG for debugging (default off) |
|
| 167 |
+
|
| 168 |
+
Dev Space can omit `SECRET_KEY` (login is disabled on both Spaces).
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## Tree library layout (unlimited depth)
|
| 173 |
+
|
| 174 |
+
Images are organized in a **configurable tree** (zone β area β year β image type, or any depth):
|
| 175 |
+
|
| 176 |
+
```text
|
| 177 |
+
library_sources/
|
| 178 |
+
central_delhi/
|
| 179 |
+
karol_bagh/
|
| 180 |
+
2025/
|
| 181 |
+
Images/
|
| 182 |
+
satellite.tif
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
- **API:** `GET /api/dda/tree`, `POST /api/dda/tree/nodes`, upload via `POST /api/dda/tree/nodes/{id}/images/upload`
|
| 186 |
+
- **UI:** Recursive tree sidebar on Library and Change Detection tabs; **Manage** (admin) for create/rename/move/delete
|
| 187 |
+
- **Storage:** Slug-based disk paths; display names in `node_path`
|
| 188 |
+
- **Local folders:** Create folders under `library_sources/{zone}/{area}/β¦/` and drop files into `Images/`; click **Refresh** (or restart app) to sync disk β DB via `POST /api/dda/local/rescan`
|
| 189 |
+
- **Legacy:** Flat `library_sources/YEAR/` files auto-migrate to `Unassigned/Legacy/{year}/Images/` on startup
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## UAT checklist (satdetect-dev)
|
| 194 |
+
|
| 195 |
+
Run before promoting any Vedangsoft feature to production:
|
| 196 |
+
|
| 197 |
+
1. **Health** β `GET /health` returns `status: ok`, `appMode: dda`, `dda.libraryImages` β₯ 0
|
| 198 |
+
2. **Tree library** β Create zone β area β year nodes (admin); upload GeoTIFF to node; tree + grid show breadcrumb
|
| 199 |
+
3. **Compare** β Tree sidebar filters image grid; T1/T2 dropdowns list all images; Run Detection completes with plausible lat/lng on georeferenced TIFFs
|
| 200 |
+
4. **Viewer** β Slider / T1 / T2 / Overlay modes; click region to locate
|
| 201 |
+
5. **Review** β Confirm and False Positive; Export confirmed CSV; Submit confirmed
|
| 202 |
+
6. **Reports** β PDF download; `/dda/reports/{id}` page; email link (if SMTP configured)
|
| 203 |
+
7. **Session isolation** β Two browsers see separate history (per-session cookie)
|
| 204 |
+
8. **Admin** β `GET /api/dda/admin/status` (analyst+); stale job reconcile after restart
|
| 205 |
+
9. **Training export** β Mark false positives β `GET /api/dda/training/export` (admin or export key)
|
| 206 |
+
10. **Security** β Path traversal blocked (`../` in library path); upload size limit enforced
|
| 207 |
+
|
| 208 |
+
Sign-off: DDA stakeholder approves sample runs on dev Space before any push to `satdetect` production.
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Quick reference
|
| 213 |
+
|
| 214 |
+
```powershell
|
| 215 |
+
# Daily work (new client)
|
| 216 |
+
git checkout master
|
| 217 |
+
# ... edit code ...
|
| 218 |
+
git add .
|
| 219 |
+
git commit -m "Your message"
|
| 220 |
+
git push hf-dev master:main
|
| 221 |
+
|
| 222 |
+
# Update live app (only when ready)
|
| 223 |
+
git checkout production
|
| 224 |
+
git merge master # or cherry-pick specific commits
|
| 225 |
+
git push hf production:main
|
| 226 |
+
git checkout master
|
| 227 |
+
```
|
DEV_SETUP.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Vedangsoft Change Detection β Local Dev Setup
|
| 2 |
+
|
| 3 |
+
This repo is the **development branch** of the satellite change-detection app (DDA SOW). It runs the full dev UI: image library, GeoTIFF comparison, async jobs, reports, and PDF export.
|
| 4 |
+
|
| 5 |
+
**Live dev Space (reference):** https://coderuday21-satdetect-dev.hf.space
|
| 6 |
+
**Production Space (do not deploy this repo there without review):** https://coderuday21-satdetect.hf.space
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1. Prerequisites
|
| 11 |
+
|
| 12 |
+
| Requirement | Notes |
|
| 13 |
+
|-------------|--------|
|
| 14 |
+
| **Python 3.10 β 3.12** | Tested with **3.11** (same as Docker). 3.13+ may have wheel issues for some packages. |
|
| 15 |
+
| **Git** | Clone this repository. |
|
| 16 |
+
| **~4 GB free disk** | PyTorch (CPU), transformers model cache, and sample GeoTIFFs. |
|
| 17 |
+
| **RAM 8 GB+ recommended** | Detection loads AdaptFormer; large GeoTIFFs use more RAM. |
|
| 18 |
+
|
| 19 |
+
### Windows (GeoTIFF / rasterio)
|
| 20 |
+
|
| 21 |
+
`rasterio` needs GDAL. Easiest options:
|
| 22 |
+
|
| 23 |
+
**Option A β pip wheels (try first):**
|
| 24 |
+
```powershell
|
| 25 |
+
pip install -r requirements.txt
|
| 26 |
+
python -c "import rasterio; print('rasterio OK', rasterio.__version__)"
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
**Option B β if rasterio fails, use Conda for GDAL then pip for the rest:**
|
| 30 |
+
```powershell
|
| 31 |
+
conda create -n dda-cd python=3.11 -y
|
| 32 |
+
conda activate dda-cd
|
| 33 |
+
conda install -c conda-forge gdal rasterio -y
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**Option C β OSGeo4W:** Install [OSGeo4W](https://trac.osgeo.org/osgeo4w/) and ensure `gdal` is on `PATH` before `pip install rasterio`.
|
| 38 |
+
|
| 39 |
+
### macOS / Linux
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
# macOS (Homebrew)
|
| 43 |
+
brew install gdal
|
| 44 |
+
|
| 45 |
+
# Ubuntu/Debian
|
| 46 |
+
sudo apt-get install gdal-bin libgdal-dev
|
| 47 |
+
export GDAL_CONFIG=/usr/bin/gdal-config
|
| 48 |
+
pip install -r requirements.txt
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## 2. Clone and install
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
git clone https://github.com/Uday-at-Vedang/Change-Detection-DEV.git
|
| 57 |
+
cd Change-Detection-DEV
|
| 58 |
+
python -m venv venv
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
**Windows:**
|
| 62 |
+
```powershell
|
| 63 |
+
venv\Scripts\activate
|
| 64 |
+
pip install -U pip setuptools wheel
|
| 65 |
+
pip install -r requirements.txt
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**macOS / Linux:**
|
| 69 |
+
```bash
|
| 70 |
+
source venv/bin/activate
|
| 71 |
+
pip install -U pip setuptools wheel
|
| 72 |
+
pip install -r requirements.txt
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
> First `pip install` may take 10β20 minutes (PyTorch + transformers).
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## 3. Environment variables (optional)
|
| 80 |
+
|
| 81 |
+
Copy the template and edit if needed:
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
cp .env.example .env
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
| Variable | Default (local) | Purpose |
|
| 88 |
+
|----------|----------------|---------|
|
| 89 |
+
| `APP_MODE` | `dda` (set by `run.py`) | `dda` = full dev UI; `legacy` = simple upload UI |
|
| 90 |
+
| `SECRET_KEY` | random fallback | Set in production |
|
| 91 |
+
| `DATABASE_URL` | SQLite in `data/` | Set to MySQL (`mysql+pymysql://...`) for production; PostgreSQL also supported |
|
| 92 |
+
| `LOCAL_LIBRARY_ROOT` | `library_sources/` | Custom image library folder |
|
| 93 |
+
| `MAX_GEOTIFF_MB` | `5120` | Max GeoTIFF upload size (MB) |
|
| 94 |
+
| `DETECTION_MAX_SIDE` | `4096` local / `2048` HF | Max pixel side for detection |
|
| 95 |
+
| `EMAIL_API_URL` | manager API | Email notifications |
|
| 96 |
+
| `SMTP_USER` / `SMTP_PASS` | β | Use SMTP if API URL empty |
|
| 97 |
+
| `PUBLIC_BASE_URL` | `http://localhost:8000` | Report links in emails |
|
| 98 |
+
|
| 99 |
+
Local dev does **not** require email config unless you test notifications.
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 4. Image library (local GeoTIFFs)
|
| 104 |
+
|
| 105 |
+
Place images under year folders (not committed to git β too large):
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
library_sources/
|
| 109 |
+
2024/
|
| 110 |
+
site_a.tif
|
| 111 |
+
2025/
|
| 112 |
+
site_b.tif
|
| 113 |
+
2026/
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
See `library_sources/README.md` for details. Supported: `.tif`, `.tiff`, `.png`, `.jpg`.
|
| 117 |
+
|
| 118 |
+
After adding files, start the app and click **Image Library β Refresh**.
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 5. Run the app
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
python run.py
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Opens **http://127.0.0.1:8000** with the Vedangsoft dev UI (Home, Image Library, Change Detection, and Reports pages).
|
| 129 |
+
|
| 130 |
+
Alternative (with auto-reload during development):
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
set APP_MODE=dda # Windows
|
| 134 |
+
export APP_MODE=dda # macOS/Linux
|
| 135 |
+
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
### First run
|
| 139 |
+
|
| 140 |
+
- Creates `data/satellite_app.db` and `data/overlays/`.
|
| 141 |
+
- Downloads **AdaptFormer** model from Hugging Face on first detection (~500 MB). Requires internet.
|
| 142 |
+
- Seed data: Delhi zone/village hierarchy is loaded automatically in DDA mode.
|
| 143 |
+
|
| 144 |
+
### Health check
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
curl http://127.0.0.1:8000/health
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Expected: `"appMode": "dda"`, `"status": "ok"`.
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## 6. Using the dev UI
|
| 155 |
+
|
| 156 |
+
1. **Image Library** β scan year folders, upload GeoTIFFs (up to 5 GB), view hierarchy.
|
| 157 |
+
2. **Change Detection** β pick Base (T1) and Comparison (T2), run detection (async jobs on HF; sync locally).
|
| 158 |
+
3. **Reports** β history, PDF download, browser report at `/dda/reports/{id}`.
|
| 159 |
+
4. **Bell icon** β in-app notifications for completed jobs.
|
| 160 |
+
5. **Review (FR-08)** β Confirm / False Positive per region, export confirmed CSV, submit to dept API (`DEPT_API_URL`).
|
| 161 |
+
6. **Session users** β Each browser gets isolated history via `dda_session_id` cookie (no login required).
|
| 162 |
+
7. **Admin** β Optional `DDA_ADMIN_EMAIL` / `DDA_ADMIN_PASSWORD` for admin role; `GET /api/dda/admin/status`.
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
## 7. Project layout (Vedangsoft)
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
app/
|
| 170 |
+
main.py # FastAPI entry
|
| 171 |
+
detection_engine.py # Change detection pipeline
|
| 172 |
+
dda/ # DDA modules (library, jobs, reports, geo)
|
| 173 |
+
static/js/dda/ # Dev frontend
|
| 174 |
+
templates/
|
| 175 |
+
partials/navbar_dda.html, result_modal_dda.html # shared includes
|
| 176 |
+
home_dda.html, library_dda.html, detect_dda.html, reports_dda.html
|
| 177 |
+
docs/IMPLEMENTATION_PLAN_DDA.md # SOW phase plan
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
---
|
| 181 |
+
|
| 182 |
+
## 8. Troubleshooting
|
| 183 |
+
|
| 184 |
+
| Issue | Fix |
|
| 185 |
+
|-------|-----|
|
| 186 |
+
| `ImportError: rasterio` | Install GDAL (see Β§1), then reinstall rasterio |
|
| 187 |
+
| Simple upload UI instead of the Vedangsoft dev UI | Set `APP_MODE=dda` or use `python run.py` |
|
| 188 |
+
| Library empty | Add `.tif` files under `library_sources/YYYY/` and click Refresh |
|
| 189 |
+
| Detection slow / OOM | Lower `DETECTION_MAX_SIDE=2048` or use smaller images |
|
| 190 |
+
| Model download fails | Check internet; set `HF_HOME` to a writable folder |
|
| 191 |
+
| Port 8000 in use | Change `PORT` in `run.py` or use `--port 8001` with uvicorn |
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## 9. Deploy to Hugging Face dev Space (maintainers)
|
| 196 |
+
|
| 197 |
+
See `DEPLOYMENT.md`. Dev Space remote:
|
| 198 |
+
|
| 199 |
+
```powershell
|
| 200 |
+
git remote add hf-dev https://huggingface.co/spaces/coderuday21/satdetect-dev
|
| 201 |
+
git push hf-dev master:main
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
Do **not** push `master` to production `satdetect` without explicit sign-off.
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## 10. Key API endpoints (Vedangsoft)
|
| 209 |
+
|
| 210 |
+
| Method | Path | Description |
|
| 211 |
+
|--------|------|-------------|
|
| 212 |
+
| GET | `/health` | Health + app mode |
|
| 213 |
+
| GET | `/api/dda/local/images` | Library image list |
|
| 214 |
+
| POST | `/api/dda/jobs` | Queue async detection |
|
| 215 |
+
| GET | `/api/dda/reports/{id}/pdf` | PDF export |
|
| 216 |
+
| GET | `/dda/reports/{id}` | Browser report page |
|
| 217 |
+
| GET | `/api/history` | Detection run history |
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## 11. Delhi evaluation workflow (accuracy calibration)
|
| 222 |
+
|
| 223 |
+
Full end-to-end reproduction of the Delhi accuracy-improvement sprint (see `Accuracy_Improvement_Plan.xlsx`). Every command below runs from the repo root.
|
| 224 |
+
|
| 225 |
+
### 11.1 Curate real Delhi pairs
|
| 226 |
+
|
| 227 |
+
`library_sources/` ships empty (real imagery isn't committed β see `library_sources/README.md`). Two ways to populate it:
|
| 228 |
+
|
| 229 |
+
```bash
|
| 230 |
+
# Free, openly-licensed Sentinel-2 imagery (10m GSD β good for large land-use
|
| 231 |
+
# change, vegetation, water bodies; not reliable for individual buildings/roads)
|
| 232 |
+
python scripts/build_delhi_pairs_sentinel2.py --count 32
|
| 233 |
+
|
| 234 |
+
# Or, once you have your own imagery (DDA-provided GeoTIFFs, drone, etc.) in
|
| 235 |
+
# library_sources/<year>/:
|
| 236 |
+
python scripts/build_delhi_manifest.py --scan # see what's available
|
| 237 |
+
python scripts/build_delhi_manifest.py --add --before library_sources/2024/x.tif \
|
| 238 |
+
--after library_sources/2026/x.tif --zone "..." --change-types building,road
|
| 239 |
+
python scripts/build_delhi_manifest.py --validate # check coverage (>=30 pairs, all 4 change types)
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### 11.2 Label ground-truth masks
|
| 243 |
+
|
| 244 |
+
```bash
|
| 245 |
+
# Semi-automated candidate generation (CVA + adaptive threshold + blob filter)
|
| 246 |
+
python scripts/generate_candidate_masks.py
|
| 247 |
+
|
| 248 |
+
# Review docs/delhi_eval/labels/candidates/previews/*.png (before | after | mask | overlay),
|
| 249 |
+
# then promote or reject each one:
|
| 250 |
+
python scripts/accept_candidate_mask.py --pair-id delhi_0001
|
| 251 |
+
python scripts/accept_candidate_mask.py --reject --pair-id delhi_0002
|
| 252 |
+
```
|
| 253 |
+
Candidate masks are a starting point, not ground truth β always eyeball the preview before promoting. See `docs/delhi_eval/manifest.json` notes per pair for known limitations found during review (e.g. diffuse changes needing hand-drawn rough polygons instead).
|
| 254 |
+
|
| 255 |
+
### 11.3 Run the detection harness against the labeled set
|
| 256 |
+
|
| 257 |
+
```bash
|
| 258 |
+
python scripts/compare_methods.py --manifest docs/delhi_eval/manifest.json \
|
| 259 |
+
--methods "AI-Based Deep Learning,Feature-Based,Hybrid Approach" --sensitivities 0.5 \
|
| 260 |
+
--out runs/delhi_baseline
|
| 261 |
+
```
|
| 262 |
+
Reports per-pair IoU/F1/precision/recall for every labeled pair (unlabeled pairs run without ground-truth scoring).
|
| 263 |
+
|
| 264 |
+
### 11.4 Grid-search calibration
|
| 265 |
+
|
| 266 |
+
```bash
|
| 267 |
+
python scripts/grid_search_calibration.py --methods "AI-Based Deep Learning" \
|
| 268 |
+
--sensitivities 0.5 --cl-qs 0.80,0.85,0.90,0.92 --manifest docs/delhi_eval/manifest.json
|
| 269 |
+
```
|
| 270 |
+
Sweeps `sensitivity` / `DETECTION_FUSION` / `DETECTION_DL_FLOOR_BASE` / `DETECTION_CL_Q_BASE` and ranks configs by mean F1 in `runs/calibration/leaderboard.csv`. **Always verify a promising config against the synthetic regression suite (11.5) before promoting it** β the highest Delhi F1 isn't automatically safe; see `runs/calibration/best_params.json` for a real example of a config that won on Delhi but broke a regression gate.
|
| 271 |
+
|
| 272 |
+
### 11.5 Synthetic regression gate (mandatory before promoting any default)
|
| 273 |
+
|
| 274 |
+
```bash
|
| 275 |
+
python scripts/validate_detection.py --benchmark --method "AI-Based Deep Learning" \
|
| 276 |
+
--sensitivity 0.5 --out runs/synthetic_check
|
| 277 |
+
```
|
| 278 |
+
Four cases: `inserted_buildings`, `brightness_only`, `misaligned_change`, `parked_cars` (the mandatory car/transient-FP guard β must stay F1=1.0). Pass `--method`/`--sensitivity` to test any calibrated config; set the relevant `DETECTION_*` env vars first to test non-default fusion parameters.
|
| 279 |
+
|
| 280 |
+
**Known gap:** the plan also references a LEVIR-CD regression case and a kappa statistic (Phase 0, marked "Complete"). Neither actually exists in this codebase as of the Day 5/6 calibration pass β only the 4 synthetic cases above are real, runnable gates. Treat any claim of "LEVIR-CD gate passing" as unverified until that harness is actually built.
|
| 281 |
+
|
| 282 |
+
### 11.6 Promote a verified config to production defaults
|
| 283 |
+
|
| 284 |
+
Once a config passes 11.5 unchanged, hardcode it as the new default in `app/detection_config.py` (e.g. `get_cl_q_base()`'s fallback value), then re-run 11.3 and 11.5 **with no env var set** to confirm the new default behaves identically to what was verified.
|
Dockerfile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Ensure build logs flush immediately (helps when HF shows βBUILDINGβ with no output)
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
|
| 6 |
+
# System dependencies for OpenCV and image processing
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
libgl1 \
|
| 9 |
+
libglib2.0-0 \
|
| 10 |
+
libsm6 \
|
| 11 |
+
libxext6 \
|
| 12 |
+
libxrender1 \
|
| 13 |
+
gdal-bin \
|
| 14 |
+
libgdal-dev \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Create non-root user (required by Hugging Face Spaces)
|
| 18 |
+
RUN useradd -m -u 1000 appuser
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Build-time info + cache-bust:
|
| 23 |
+
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
+
ARG APP_BUILD=38
|
| 25 |
+
ENV MAX_GEOTIFF_MB=15360
|
| 26 |
+
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
+
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
| 28 |
+
RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
|
| 29 |
+
|
| 30 |
+
# Install Python dependencies
|
| 31 |
+
COPY requirements.txt .
|
| 32 |
+
RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 -U pip setuptools wheel
|
| 33 |
+
RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 --prefer-binary -r requirements.txt -v
|
| 34 |
+
|
| 35 |
+
# Pre-download the AdaptFormer model so cold starts are instant
|
| 36 |
+
ENV HF_HOME=/app/.hf_cache
|
| 37 |
+
RUN python -c "from transformers import AutoImageProcessor, AutoModel; \
|
| 38 |
+
AutoImageProcessor.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache', trust_remote_code=True); \
|
| 39 |
+
AutoModel.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache', trust_remote_code=True); \
|
| 40 |
+
print('Model pre-downloaded successfully')"
|
| 41 |
+
|
| 42 |
+
# Copy application code
|
| 43 |
+
COPY . .
|
| 44 |
+
|
| 45 |
+
# Create data directories with correct permissions
|
| 46 |
+
RUN mkdir -p data/overlays && chown -R appuser:appuser /app
|
| 47 |
+
|
| 48 |
+
USER appuser
|
| 49 |
+
|
| 50 |
+
# HF Spaces generally uses 7860, but binding to $PORT is safer.
|
| 51 |
+
ENV PORT=7860
|
| 52 |
+
EXPOSE 7860
|
| 53 |
+
|
| 54 |
+
# Bind to runtime PORT so health checks always reach the server.
|
| 55 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info --timeout-keep-alive 600"]
|
README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Vedangsoft Change Detection (Dev)
|
| 3 |
+
emoji: π°οΈ
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Vedangsoft Change Detection β Development
|
| 11 |
+
|
| 12 |
+
Satellite / drone **change detection** web app for the DDA Scope of Work. This repository tracks **dev-only** features (`APP_MODE=dda`): image library, GeoTIFF comparison, async jobs, geo-referenced regions, reports, and PDF export.
|
| 13 |
+
|
| 14 |
+
> **New to the project?** Start with **[DEV_SETUP.md](DEV_SETUP.md)** β step-by-step install and run instructions for your machine.
|
| 15 |
+
|
| 16 |
+
| Environment | URL |
|
| 17 |
+
|-------------|-----|
|
| 18 |
+
| **Dev Space (HF)** | https://coderuday21-satdetect-dev.hf.space |
|
| 19 |
+
| **Local** | http://127.0.0.1:8000 after `python run.py` |
|
| 20 |
+
|
| 21 |
+
## Quick start
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
git clone https://github.com/Uday-at-Vedang/Change-Detection-DEV.git
|
| 25 |
+
cd Change-Detection-DEV
|
| 26 |
+
python -m venv venv
|
| 27 |
+
venv\Scripts\activate # Windows
|
| 28 |
+
# source venv/bin/activate # macOS/Linux
|
| 29 |
+
pip install -r requirements.txt
|
| 30 |
+
python run.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
1. Add GeoTIFFs to `library_sources/2025/` (or other year folders).
|
| 34 |
+
2. Open **Image Library β Refresh**.
|
| 35 |
+
3. Use **Change Detection** to compare T1 vs T2.
|
| 36 |
+
|
| 37 |
+
Full details: **[DEV_SETUP.md](DEV_SETUP.md)** Β· SOW plan: **`docs/IMPLEMENTATION_PLAN_DDA.md`** Β· HF deploy: **`DEPLOYMENT.md`**
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
# Satellite Change Detection β Standalone Web App
|
| 42 |
+
|
| 43 |
+
Standalone web application for satellite image change detection with **database storage** and a **clean, modern UI**. Opens directly on the detection page β no sign-in required.
|
| 44 |
+
|
| 45 |
+
## Features
|
| 46 |
+
|
| 47 |
+
- **Vedangsoft dev UI** β Home, Library, Detection, Reports pages (when `APP_MODE=dda`)
|
| 48 |
+
- **Direct access** β upload and run detection immediately (no login)
|
| 49 |
+
- **Database** β SQLite by default; MySQL in production via `DATABASE_URL` (PostgreSQL also supported); stores detection runs
|
| 50 |
+
- **Change detection** β AdaptFormer deep learning + hybrid / difference methods
|
| 51 |
+
- **GeoTIFF library** β Year-folder library, 5 GB upload limit, lat/lng on regions
|
| 52 |
+
- **Object classification** β Changed regions labeled (Water, Vegetation, Building, Road, etc.)
|
| 53 |
+
- **History & reports** β Past runs, PDF export, email notifications
|
| 54 |
+
- **Region polygons** β Engine footprints (`polygon` / `polygonGeo`) in the result overlay + viewer SVG; export via `GET /api/dda/reports/{id}/export.geojson` (bbox/Point fallback for older runs). Verify: `python scripts/verify_polygon_mvp.py`
|
| 55 |
+
|
| 56 |
+
## Setup (legacy summary)
|
| 57 |
+
|
| 58 |
+
See **[DEV_SETUP.md](DEV_SETUP.md)** for the complete guide. Minimal steps:
|
| 59 |
+
|
| 60 |
+
1. **Create a virtual environment (recommended)**
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
cd change_detection_webapp
|
| 64 |
+
python -m venv venv
|
| 65 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
2. **Install dependencies**
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
pip install -r requirements.txt
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
3. **Run the app**
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
python run.py
|
| 78 |
+
# or: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
4. Open **http://localhost:8000** in your browser.
|
| 82 |
+
|
| 83 |
+
## First run
|
| 84 |
+
|
| 85 |
+
- The DB (SQLite locally, or the configured `DATABASE_URL`) and `data/` (overlay images) are created automatically on first use.
|
| 86 |
+
- **DDA mode:** place images in `library_sources/YYYY/` and use the Library tab.
|
| 87 |
+
- **Legacy mode:** upload Before/After PNGs on the detection page.
|
| 88 |
+
- AdaptFormer model downloads from Hugging Face on first detection (~500 MB).
|
| 89 |
+
|
| 90 |
+
## Configuration
|
| 91 |
+
|
| 92 |
+
- **DDA mode:** `APP_MODE=dda` (default when using `run.py`). Copy `.env.example` β `.env` for optional overrides.
|
| 93 |
+
- **Database**: set `DATABASE_URL` to use another DB β MySQL (production for this project): `mysql+pymysql://user:pass@host:3306/db`, or PostgreSQL: `postgresql://user:pass@host/db`. Otherwise SQLite under `data/satellite_app.db` is used.
|
| 94 |
+
- **JWT**: set `SECRET_KEY` via env in production.
|
| 95 |
+
- **Email**: `EMAIL_API_URL` or `SMTP_USER` / `SMTP_PASS` β see `.env.example`.
|
| 96 |
+
|
| 97 |
+
## Project layout
|
| 98 |
+
|
| 99 |
+
```
|
| 100 |
+
change_detection_webapp/
|
| 101 |
+
βββ app/
|
| 102 |
+
β βββ main.py # FastAPI app, routes
|
| 103 |
+
β βββ dda/ # DDA dev modules (library, jobs, reports)
|
| 104 |
+
β βββ detection_engine.py # Change detection pipeline
|
| 105 |
+
βββ static/js/dda/ # DDA frontend
|
| 106 |
+
βββ templates/
|
| 107 |
+
β βββ partials/navbar_dda.html, result_modal_dda.html # shared includes
|
| 108 |
+
β βββ home_dda.html, library_dda.html, detect_dda.html,
|
| 109 |
+
β β reports_dda.html # Vedangsoft dev UI pages
|
| 110 |
+
β βββ login_dda.html, report_dda.html
|
| 111 |
+
β βββ index.html # legacy (APP_MODE=legacy) UI
|
| 112 |
+
βββ library_sources/ # Local GeoTIFF library (year folders)
|
| 113 |
+
βββ docs/IMPLEMENTATION_PLAN_DDA.md
|
| 114 |
+
βββ DEV_SETUP.md # Colleague setup guide
|
| 115 |
+
βββ requirements.txt
|
| 116 |
+
βββ run.py # Local launcher (APP_MODE=dda)
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## API (Vedangsoft highlights)
|
| 120 |
+
|
| 121 |
+
- `GET /health` β app mode and version
|
| 122 |
+
- `GET /api/dda/local/images` β library image list
|
| 123 |
+
- `POST /api/dda/jobs` β queue async detection
|
| 124 |
+
- `GET /api/dda/reports/{id}/pdf` β PDF download
|
| 125 |
+
- `GET /dda/reports/{id}` β browser report page
|
| 126 |
+
- `POST /api/detect` β legacy multipart upload detect
|
| 127 |
+
- `GET /api/history` β detection run history
|
| 128 |
+
|
| 129 |
+
## Hugging Face: Space stuck on βRestartingβ
|
| 130 |
+
|
| 131 |
+
1. Open your Space β **Settings** β under **Build**, click **Clear build cache** β Save. Then trigger a rebuild (push a commit or click **Restart**).
|
| 132 |
+
2. Check **Logs** (Build logs + App logs) for Python errors or βKilledβ (out of memory).
|
| 133 |
+
3. In **Settings** β **Hardware**, try a slightly larger CPU/memory if available.
|
WORKFLOW.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Satellite Change Detection β Project Workflow Guide
|
| 2 |
+
|
| 3 |
+
A complete walkthrough of how this project is structured, how a detection request
|
| 4 |
+
flows through the system, and how to run it locally. Read this top-to-bottom to get
|
| 5 |
+
familiar with the codebase.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. What this project does
|
| 10 |
+
|
| 11 |
+
This is a **standalone web application for satellite image change detection**. A user
|
| 12 |
+
uploads two images of the same place taken at different times (a **"before"** and an
|
| 13 |
+
**"after"**), and the app:
|
| 14 |
+
|
| 15 |
+
1. Aligns the two images (registration).
|
| 16 |
+
2. Normalizes their colors/brightness.
|
| 17 |
+
3. Runs change detection (classical computer vision + a pretrained deep-learning model).
|
| 18 |
+
4. Finds and classifies the changed regions (new buildings, vegetation change, water, etc.).
|
| 19 |
+
5. Draws the changes on an overlay image and shows statistics.
|
| 20 |
+
6. Saves the run to a per-user history (with login/accounts).
|
| 21 |
+
|
| 22 |
+
It is deployed as a Docker container on **Hugging Face Spaces**, but runs the same way locally.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 2. Tech stack
|
| 27 |
+
|
| 28 |
+
| Layer | Technology |
|
| 29 |
+
|-------|-----------|
|
| 30 |
+
| Backend API | **FastAPI** (Python), served by **uvicorn** |
|
| 31 |
+
| Image processing | **OpenCV**, **NumPy**, **scikit-learn** |
|
| 32 |
+
| Deep learning | **PyTorch** + **Hugging Face Transformers** (AdaptFormer model) |
|
| 33 |
+
| Database | **SQLAlchemy** ORM over **SQLite** (default) or **PostgreSQL** |
|
| 34 |
+
| Auth | **JWT** tokens (python-jose) + **bcrypt** password hashing (passlib) |
|
| 35 |
+
| Frontend | Plain **HTML + CSS + vanilla JavaScript** (single-page app) |
|
| 36 |
+
| Email | HTTP email API or SMTP (optional notifications) |
|
| 37 |
+
| Deployment | **Docker** β Hugging Face Spaces |
|
| 38 |
+
|
| 39 |
+
There is **no build step** for the frontend β it's static files served directly.
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## 3. Repository structure
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
change_detection_webapp/
|
| 47 |
+
βββ app/
|
| 48 |
+
β βββ main.py # FastAPI app + all HTTP routes (entry point)
|
| 49 |
+
β βββ detection_engine.py # CORE: preprocessing, registration, detection, classification
|
| 50 |
+
β βββ model_inference.py # AdaptFormer deep-learning model loader + tiled inference
|
| 51 |
+
β βββ cd_models/ # Extra DL building blocks
|
| 52 |
+
β β βββ change_model.py # Siamese U-Net architecture (optional, needs weights)
|
| 53 |
+
β β βββ model_utils.py # tiling, multi-scale, confidence-map helpers
|
| 54 |
+
β βββ auth.py # JWT create/verify, password hashing, current-user lookup
|
| 55 |
+
β βββ database.py # SQLAlchemy engine/session, DATA_DIR resolution
|
| 56 |
+
β βββ models.py # ORM models: User, DetectionRun
|
| 57 |
+
β βββ notifier.py # Email notification sending
|
| 58 |
+
βββ static/
|
| 59 |
+
β βββ css/style.css # All styles
|
| 60 |
+
β βββ js/app.js # All frontend logic (auth, upload, render results)
|
| 61 |
+
βββ templates/
|
| 62 |
+
β βββ index.html # Single-page UI shell
|
| 63 |
+
βββ scripts/
|
| 64 |
+
β βββ validate_detection.py # Sanity checks for the detection pipeline
|
| 65 |
+
βββ data/ # Created at runtime: SQLite DB + overlay/thumbnail images
|
| 66 |
+
βββ requirements.txt # Python dependencies
|
| 67 |
+
βββ Dockerfile # Container build (also pre-downloads the model)
|
| 68 |
+
βββ README.md # Short setup notes (Hugging Face front-matter on top)
|
| 69 |
+
βββ WORKFLOW.md # This document
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
> Note: the `app/cd_models/` folder is named `cd_models` (not `models`) on purpose β
|
| 73 |
+
> `app/models.py` already exists for the database models, and a folder named `models`
|
| 74 |
+
> would shadow it and break imports.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 4. High-level architecture
|
| 79 |
+
|
| 80 |
+
```mermaid
|
| 81 |
+
flowchart LR
|
| 82 |
+
browser["Browser (index.html + app.js)"] -->|"HTTP / JSON"| api["FastAPI (main.py)"]
|
| 83 |
+
api --> auth["auth.py (JWT)"]
|
| 84 |
+
api --> engine["detection_engine.py"]
|
| 85 |
+
engine --> model["model_inference.py (AdaptFormer)"]
|
| 86 |
+
api --> db["(SQLite / Postgres via models.py)"]
|
| 87 |
+
api --> notifier["notifier.py (email)"]
|
| 88 |
+
api --> files["data/overlays (result images)"]
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
- The **browser** talks only to FastAPI over JSON + multipart form uploads.
|
| 92 |
+
- **main.py** is the only place that defines routes; it delegates the heavy lifting to
|
| 93 |
+
`detection_engine.run_detection(...)`.
|
| 94 |
+
- Results (overlay PNGs, thumbnails) are written to `data/overlays/` and referenced by URL.
|
| 95 |
+
- Run metadata is stored in the database.
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 5. The detection pipeline (the core workflow)
|
| 100 |
+
|
| 101 |
+
This is the most important part to understand. Everything happens inside
|
| 102 |
+
`run_detection()` in [`app/detection_engine.py`](app/detection_engine.py).
|
| 103 |
+
|
| 104 |
+
```mermaid
|
| 105 |
+
flowchart TD
|
| 106 |
+
start["run_detection(before, after, method, sensitivity, ...)"] --> pre["preprocess_image() -- RGB, resize, denoise"]
|
| 107 |
+
pre --> reg{"registration enabled?"}
|
| 108 |
+
reg -->|yes| align["register_images() -- SIFT/ORB + ECC align, returns quality metrics"]
|
| 109 |
+
reg -->|no| norm
|
| 110 |
+
align --> norm{"normalization enabled?"}
|
| 111 |
+
norm -->|yes| radio["normalize_radiometry() -- LAB color match + CLAHE"]
|
| 112 |
+
norm -->|no| method
|
| 113 |
+
radio --> method["pick detection method"]
|
| 114 |
+
method --> ai["AI-Based Deep Learning (default)"]
|
| 115 |
+
method --> diff["Image Difference"]
|
| 116 |
+
method --> feat["Feature-Based (KMeans)"]
|
| 117 |
+
method --> hyb["Hybrid / Hybrid AI"]
|
| 118 |
+
ai --> fuse["AdaptFormer score + classical score -> confidence-gated fusion"]
|
| 119 |
+
diff --> mask
|
| 120 |
+
feat --> mask
|
| 121 |
+
hyb --> fuse
|
| 122 |
+
fuse --> mask["binary change mask"]
|
| 123 |
+
mask --> regions["analyze_change_regions() -- connected components + classify + NMS"]
|
| 124 |
+
regions --> viz["visualize_changes() -- draw boxes on overlay"]
|
| 125 |
+
viz --> out["return mask, overlay, stats, regions"]
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### Step by step
|
| 129 |
+
|
| 130 |
+
1. **Preprocess** (`preprocess_image`): convert to RGB, cap the size (default 1600px max
|
| 131 |
+
side) for speed, apply light Gaussian blur (and a bilateral filter only when the image
|
| 132 |
+
is noisy) to reduce sensor noise without destroying edges.
|
| 133 |
+
|
| 134 |
+
2. **Register / align** (`register_images`): the two screenshots rarely line up perfectly.
|
| 135 |
+
- First tries **SIFT + FLANN** feature matching β homography.
|
| 136 |
+
- Falls back to **ORB** features if SIFT is weak.
|
| 137 |
+
- Refines with **ECC** (sub-pixel alignment).
|
| 138 |
+
- Falls back to **multi-scale ECC** if feature matching fails entirely.
|
| 139 |
+
- Returns an `(img1, img2_aligned, registration_ok, reg_meta)` tuple. `registration_ok`
|
| 140 |
+
and the quality metrics (inlier ratio, NCC) are surfaced to the UI as an
|
| 141 |
+
**alignment warning** when the alignment is weak β important for Google Earth
|
| 142 |
+
screenshots that differ in zoom/crop.
|
| 143 |
+
|
| 144 |
+
3. **Radiometric normalization** (`normalize_radiometry`): match the "after" image's color
|
| 145 |
+
statistics to the "before" image in LAB space, plus symmetric CLAHE on the lightness
|
| 146 |
+
channel, so lighting/season differences don't look like real change.
|
| 147 |
+
|
| 148 |
+
4. **Detection method** (selected by the `method` argument):
|
| 149 |
+
- **AI-Based Deep Learning** (default): runs the **AdaptFormer** model
|
| 150 |
+
(`model_inference.predict_change_mask`) to get a per-pixel change probability map,
|
| 151 |
+
computes a **classical** multi-channel score map (color ΞE, SSIM, texture/LBP, edges,
|
| 152 |
+
change-vector analysis), then **fuses** them with `fuse_dl_and_classical()`. Fusion is
|
| 153 |
+
**confidence-gated** (not a blind union): the model drives structural changes, the
|
| 154 |
+
classical signal + Excess-Green index supports vegetation changes.
|
| 155 |
+
- **Image Difference**: LAB ΞE difference with adaptive (Otsu/MAD) thresholding.
|
| 156 |
+
- **Feature-Based**: KMeans clustering of per-pixel difference features.
|
| 157 |
+
- **Hybrid / Hybrid AI**: weighted combinations of the above.
|
| 158 |
+
|
| 159 |
+
5. **Clean the mask** (`_clean_mask`): morphological open/close, hole filling, remove tiny
|
| 160 |
+
or thin (shadow-like) components.
|
| 161 |
+
|
| 162 |
+
6. **Find & classify regions** (`analyze_change_regions`): connected-components on the mask
|
| 163 |
+
β bounding boxes β `classify_object_type()` assigns a label (New Construction/Building,
|
| 164 |
+
Vegetation Change, Water Body Change, Road/Pavement, Bare Land, etc.) with a confidence
|
| 165 |
+
and severity β **non-maximum suppression** removes overlapping/duplicate boxes.
|
| 166 |
+
|
| 167 |
+
7. **Visualize** (`visualize_changes`): draw a subtle tint on changed pixels and numbered,
|
| 168 |
+
color-coded boxes for the top regions.
|
| 169 |
+
|
| 170 |
+
8. **Return** `change_mask, result_image, stats, change_regions` back to `main.py`.
|
| 171 |
+
|
| 172 |
+
### Sensitivity
|
| 173 |
+
|
| 174 |
+
The `detection_sensitivity` slider (0β1, default 0.5) shifts thresholds: higher = detect
|
| 175 |
+
more (more recall, more false positives), lower = stricter (fewer, higher-confidence
|
| 176 |
+
detections).
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## 6. The deep-learning model
|
| 181 |
+
|
| 182 |
+
- File: [`app/model_inference.py`](app/model_inference.py)
|
| 183 |
+
- Model: **`deepang/adaptformer-LEVIR-CD`** (a change-detection transformer trained on the
|
| 184 |
+
LEVIR-CD building dataset), pulled from the Hugging Face Hub.
|
| 185 |
+
- It is **pre-downloaded at Docker build time** (see the Dockerfile) so cold starts are fast,
|
| 186 |
+
and **preloaded on app startup** so the first request isn't slow.
|
| 187 |
+
- Inference is **tile-based**: the image is split into overlapping 256Γ256 tiles (the model's
|
| 188 |
+
native size), each tile is predicted, and the results are stitched back with a
|
| 189 |
+
raised-cosine blend to avoid visible seams.
|
| 190 |
+
- If PyTorch/Transformers aren't installed or the model fails to load, the engine **falls
|
| 191 |
+
back gracefully** to the classical-only path β the app still works.
|
| 192 |
+
|
| 193 |
+
`app/cd_models/change_model.py` contains a from-scratch **Siamese U-Net** as an alternative
|
| 194 |
+
DL backbone. It only activates if a trained weights file exists at
|
| 195 |
+
`app/cd_models/weights/siamese_unet_cd.pt` (none is shipped), so it's currently dormant.
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## 7. Request lifecycle (auth + detect)
|
| 200 |
+
|
| 201 |
+
```mermaid
|
| 202 |
+
sequenceDiagram
|
| 203 |
+
participant U as Browser
|
| 204 |
+
participant A as FastAPI (main.py)
|
| 205 |
+
participant Au as auth.py
|
| 206 |
+
participant E as detection_engine.py
|
| 207 |
+
participant D as Database
|
| 208 |
+
|
| 209 |
+
U->>A: POST /api/auth/login {email, password}
|
| 210 |
+
A->>Au: verify_password + create_access_token
|
| 211 |
+
Au-->>A: JWT
|
| 212 |
+
A-->>U: token (also set as httpOnly cookie)
|
| 213 |
+
|
| 214 |
+
U->>A: POST /api/detect (before, after, method, ...) + token
|
| 215 |
+
A->>Au: resolve user from token/cookie
|
| 216 |
+
A->>E: run_detection(...)
|
| 217 |
+
E-->>A: mask, overlay, stats, regions
|
| 218 |
+
A->>D: save DetectionRun row
|
| 219 |
+
A->>A: write overlay + thumbnails to data/overlays/
|
| 220 |
+
A-->>U: JSON {statistics, regions, overlayBase64Png, ...}
|
| 221 |
+
U->>U: app.js renders overlay + regions table
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## 8. Data model
|
| 227 |
+
|
| 228 |
+
Defined in [`app/models.py`](app/models.py):
|
| 229 |
+
|
| 230 |
+
- **User**: `id, email (unique), hashed_password, full_name, created_at`
|
| 231 |
+
- **DetectionRun**: `id, user_id, title, method, total_pixels, changed_pixels,
|
| 232 |
+
change_percentage, regions_count, overlay_path, before/after thumbnail paths, zone,
|
| 233 |
+
village, regions_json (serialized regions), created_at`
|
| 234 |
+
|
| 235 |
+
The database file lives at `data/satellite_app.db` (SQLite) and is created automatically on
|
| 236 |
+
first run. Set `DATABASE_URL` to use PostgreSQL instead.
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## 9. API reference
|
| 241 |
+
|
| 242 |
+
All routes are in [`app/main.py`](app/main.py).
|
| 243 |
+
|
| 244 |
+
| Method & path | Purpose |
|
| 245 |
+
|---------------|---------|
|
| 246 |
+
| `POST /api/auth/register` | Create account `{email, password, full_name}` β token |
|
| 247 |
+
| `POST /api/auth/login` | Log in `{email, password}` β token (+ cookie) |
|
| 248 |
+
| `POST /api/auth/logout` | Clear auth cookie |
|
| 249 |
+
| `POST /api/auth/reset-password` | Set a new password `{email, new_password}` |
|
| 250 |
+
| `GET /api/me` | Current user (requires auth) |
|
| 251 |
+
| `POST /api/detect` | **Main endpoint.** multipart: `before`, `after` files + `method`, `title`, `zone`, `village`, `enable_registration`, `enable_normalization`, `detection_sensitivity`, `min_region_area`, `notify_email` |
|
| 252 |
+
| `GET /api/history` | List the current user's past runs |
|
| 253 |
+
| `POST /api/notify/test` | Send a test email |
|
| 254 |
+
| `GET /api/overlay/<path>` | Serve a saved overlay / thumbnail image |
|
| 255 |
+
| `GET /health` | Lightweight health check (no DB) |
|
| 256 |
+
| `GET /` | Serves the single-page UI |
|
| 257 |
+
|
| 258 |
+
`POST /api/detect` returns: `statistics` (pixels, change %, threshold debug, alignment
|
| 259 |
+
warning), `regions` (list with type/confidence/severity/bbox), and the overlay as base64 PNG.
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## 10. Frontend
|
| 264 |
+
|
| 265 |
+
- [`templates/index.html`](templates/index.html) is the shell: login/register forms, the
|
| 266 |
+
upload form, history table, and the result modal.
|
| 267 |
+
- [`static/js/app.js`](static/js/app.js) handles everything client-side: calling the API,
|
| 268 |
+
storing the token, submitting the detect form, and rendering results (the before/after
|
| 269 |
+
slider, the regions table, alignment warnings, and fusion stats).
|
| 270 |
+
- [`static/css/style.css`](static/css/style.css) holds all styling.
|
| 271 |
+
- Cache-busting: the `?v=NN` suffixes on the CSS/JS `<link>`/`<script>` tags are bumped when
|
| 272 |
+
those files change so browsers don't serve stale copies.
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## 11. Running locally
|
| 277 |
+
|
| 278 |
+
### Option A β Python virtual environment (recommended for development)
|
| 279 |
+
|
| 280 |
+
```bash
|
| 281 |
+
cd change_detection_webapp
|
| 282 |
+
|
| 283 |
+
# 1. Create and activate a virtual environment
|
| 284 |
+
python -m venv venv
|
| 285 |
+
# Windows (PowerShell):
|
| 286 |
+
venv\Scripts\Activate.ps1
|
| 287 |
+
# macOS / Linux:
|
| 288 |
+
source venv/bin/activate
|
| 289 |
+
|
| 290 |
+
# 2. Install dependencies (this pulls CPU PyTorch + Transformers, ~couple GB)
|
| 291 |
+
pip install -r requirements.txt
|
| 292 |
+
|
| 293 |
+
# 3. Run the dev server (auto-reload on code changes)
|
| 294 |
+
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
Open **http://localhost:8000**, register an account, then upload a before/after pair.
|
| 298 |
+
|
| 299 |
+
> First detection run downloads the AdaptFormer model from Hugging Face (one time).
|
| 300 |
+
> If you don't have PyTorch installed or want a lighter setup, the app still runs using
|
| 301 |
+
> the classical detection path only.
|
| 302 |
+
|
| 303 |
+
### Option B β Docker (mirrors production)
|
| 304 |
+
|
| 305 |
+
```bash
|
| 306 |
+
cd change_detection_webapp
|
| 307 |
+
docker build -t change-detection .
|
| 308 |
+
docker run -p 7860:7860 change-detection
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
Open **http://localhost:7860**. The Docker build pre-downloads the model, so the first
|
| 312 |
+
request is fast.
|
| 313 |
+
|
| 314 |
+
### Validate the pipeline
|
| 315 |
+
|
| 316 |
+
```bash
|
| 317 |
+
python scripts/validate_detection.py
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
This runs registration/fusion/end-to-end sanity checks on synthetic images.
|
| 321 |
+
|
| 322 |
+
---
|
| 323 |
+
|
| 324 |
+
## 12. Configuration (environment variables)
|
| 325 |
+
|
| 326 |
+
| Variable | Default | Purpose |
|
| 327 |
+
|----------|---------|---------|
|
| 328 |
+
| `DATABASE_URL` | `sqlite:///data/satellite_app.db` | Use PostgreSQL by setting this |
|
| 329 |
+
| `SECRET_KEY` | (in `auth.py`) | JWT signing key β **set this in production** |
|
| 330 |
+
| `HF_HOME` | `/app/.hf_cache` (Docker) | Where the model is cached |
|
| 331 |
+
| `EMAIL_API_URL` | manager's API | Email backend; empty + `SMTP_USER`/`SMTP_PASS` for SMTP |
|
| 332 |
+
| `PORT` | `7860` | Port uvicorn binds to in the container |
|
| 333 |
+
| `SPACE_ID` | (set by HF) | When present, data is written to `~/data` |
|
| 334 |
+
|
| 335 |
+
---
|
| 336 |
+
|
| 337 |
+
## 13. Deployment (Hugging Face Spaces)
|
| 338 |
+
|
| 339 |
+
- The Space is a **Docker** Space. Pushing to its git remote triggers a rebuild.
|
| 340 |
+
- Hugging Face builds from the **`main`** branch. (This repo's working branch is `master`,
|
| 341 |
+
so deploys push `master` β both `master` and `main` on the HF remote.)
|
| 342 |
+
- The `ARG APP_BUILD=NN` line in the Dockerfile is a cache-buster: bumping it forces pip to
|
| 343 |
+
reinstall and the model to re-download on the next build.
|
| 344 |
+
- The YAML front-matter at the top of `README.md` (title, emoji, `sdk: docker`,
|
| 345 |
+
`app_port: 7860`) is what Hugging Face reads to configure the Space.
|
| 346 |
+
|
| 347 |
+
---
|
| 348 |
+
|
| 349 |
+
## 14. Common tasks & gotchas
|
| 350 |
+
|
| 351 |
+
- **"It's detecting too much / too little"** β adjust the sensitivity slider, and make sure
|
| 352 |
+
the before/after images are the **same location, zoom, and crop**. Weak alignment shows an
|
| 353 |
+
alignment warning in the result panel.
|
| 354 |
+
- **Changed CSS/JS but nothing updates** β bump the `?v=NN` query string in `index.html`.
|
| 355 |
+
- **Adding a DB column** β update `app/models.py`; the app creates tables on startup
|
| 356 |
+
(there's lightweight migration handling in `main.py`).
|
| 357 |
+
- **Don't create a folder named `app/models/`** β it shadows `app/models.py`.
|
| 358 |
+
- **Model not loading** β the app logs a warning and continues with classical detection;
|
| 359 |
+
check the logs for the AdaptFormer load message.
|
| 360 |
+
|
| 361 |
+
---
|
| 362 |
+
|
| 363 |
+
## 15. Where to start reading the code
|
| 364 |
+
|
| 365 |
+
1. [`app/main.py`](app/main.py) β see the routes, especially `POST /api/detect`.
|
| 366 |
+
2. [`app/detection_engine.py`](app/detection_engine.py) β start at `run_detection()` at the
|
| 367 |
+
bottom and follow the calls upward.
|
| 368 |
+
3. [`app/model_inference.py`](app/model_inference.py) β how the DL model is loaded and run.
|
| 369 |
+
4. [`static/js/app.js`](static/js/app.js) β how the frontend calls the API and renders results.
|
| 370 |
+
|
| 371 |
+
Welcome to the project!
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Satellite Change Detection Web App
|
app/auth.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import secrets
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from typing import Optional, Tuple
|
| 8 |
+
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from passlib.context import CryptContext
|
| 11 |
+
from fastapi import Depends, Request
|
| 12 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
|
| 15 |
+
from .database import get_db
|
| 16 |
+
from .models import User, LoginHistory, PasswordResetToken, LoginOtp
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_env_secret = os.environ.get("SECRET_KEY", "").strip()
|
| 21 |
+
if _env_secret:
|
| 22 |
+
SECRET_KEY = _env_secret
|
| 23 |
+
else:
|
| 24 |
+
# Never fall back to a fixed/shared string here β a hardcoded value in
|
| 25 |
+
# source is visible to anyone with the repo, and could be used to forge a
|
| 26 |
+
# valid login cookie for any user id without ever knowing a password.
|
| 27 |
+
# A fresh random secret per process closes that hole; the trade-off is
|
| 28 |
+
# every existing session is invalidated on each restart until a
|
| 29 |
+
# persistent SECRET_KEY is set in the environment (see .env.example).
|
| 30 |
+
SECRET_KEY = secrets.token_hex(32)
|
| 31 |
+
logger.warning(
|
| 32 |
+
"SECRET_KEY env var not set β generated a random one-time secret for "
|
| 33 |
+
"this process (existing sessions will be invalidated on restart). "
|
| 34 |
+
"Set SECRET_KEY to a persistent random string in your environment, "
|
| 35 |
+
"especially in production β see .env.example."
|
| 36 |
+
)
|
| 37 |
+
ALGORITHM = "HS256"
|
| 38 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 39 |
+
COOKIE_NAME = "satellite_token"
|
| 40 |
+
|
| 41 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 42 |
+
security = HTTPBearer(auto_error=False)
|
| 43 |
+
|
| 44 |
+
# Brute-force lockout thresholds. IP-based is the primary defense (an attacker
|
| 45 |
+
# can't lock out a legitimate user just by failing their password from
|
| 46 |
+
# elsewhere); account-based is secondary. Both env-configurable.
|
| 47 |
+
LOGIN_IP_MAX_FAILURES = int(os.environ.get("LOGIN_IP_MAX_FAILURES", "20"))
|
| 48 |
+
LOGIN_IP_WINDOW_MINUTES = int(os.environ.get("LOGIN_IP_WINDOW_MINUTES", "15"))
|
| 49 |
+
LOGIN_ACCOUNT_MAX_FAILURES = int(os.environ.get("LOGIN_ACCOUNT_MAX_FAILURES", "5"))
|
| 50 |
+
LOGIN_ACCOUNT_WINDOW_MINUTES = int(os.environ.get("LOGIN_ACCOUNT_WINDOW_MINUTES", "15"))
|
| 51 |
+
|
| 52 |
+
_COOKIE_SECURE_ENV = os.environ.get("COOKIE_SECURE", "").strip().lower()
|
| 53 |
+
if _COOKIE_SECURE_ENV in ("1", "true", "yes"):
|
| 54 |
+
IS_SECURE_COOKIE = True
|
| 55 |
+
elif _COOKIE_SECURE_ENV in ("0", "false", "no"):
|
| 56 |
+
IS_SECURE_COOKIE = False
|
| 57 |
+
else:
|
| 58 |
+
# Render sets RENDER=true on its platform (genuine HTTPS hosting).
|
| 59 |
+
# Deliberately NOT keying off SPACE_ID: this project's own .env sets
|
| 60 |
+
# SPACE_ID=local-dev/satdetect-dev for *local* HTTP dev (to force
|
| 61 |
+
# "hosted" UI behavior, unrelated to HTTPS), so treating any SPACE_ID as
|
| 62 |
+
# "we're on HTTPS" would wrongly mark local dev secure and silently drop
|
| 63 |
+
# the auth cookie (verified via smoke test β this was the actual bug).
|
| 64 |
+
# A genuine Hugging Face Spaces deployment should set COOKIE_SECURE=true
|
| 65 |
+
# explicitly via the branch above.
|
| 66 |
+
IS_SECURE_COOKIE = bool(os.environ.get("RENDER"))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_client_ip(request: Request) -> str:
|
| 70 |
+
"""Real client IP behind Render's proxy (no --proxy-headers on uvicorn, so
|
| 71 |
+
request.client.host would otherwise report the proxy's IP, not the visitor's)."""
|
| 72 |
+
xff = request.headers.get("x-forwarded-for", "")
|
| 73 |
+
if xff:
|
| 74 |
+
return xff.split(",")[0].strip()
|
| 75 |
+
return request.client.host if request.client else ""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def get_user_agent(request: Request) -> str:
|
| 79 |
+
return request.headers.get("user-agent", "")[:512]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def check_ip_throttle(db: Session, ip: str) -> bool:
|
| 83 |
+
"""True if this IP has too many recent failed login attempts."""
|
| 84 |
+
if not ip:
|
| 85 |
+
return False
|
| 86 |
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOGIN_IP_WINDOW_MINUTES)
|
| 87 |
+
count = (
|
| 88 |
+
db.query(LoginHistory)
|
| 89 |
+
.filter(
|
| 90 |
+
LoginHistory.ip_address == ip,
|
| 91 |
+
LoginHistory.event_type == "login_failed",
|
| 92 |
+
LoginHistory.created_at >= cutoff,
|
| 93 |
+
)
|
| 94 |
+
.count()
|
| 95 |
+
)
|
| 96 |
+
return count >= LOGIN_IP_MAX_FAILURES
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def check_account_lockout(db: Session, email: str) -> bool:
|
| 100 |
+
"""True if this email has too many recent failed login attempts."""
|
| 101 |
+
if not email:
|
| 102 |
+
return False
|
| 103 |
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=LOGIN_ACCOUNT_WINDOW_MINUTES)
|
| 104 |
+
count = (
|
| 105 |
+
db.query(LoginHistory)
|
| 106 |
+
.filter(
|
| 107 |
+
LoginHistory.attempted_email == email,
|
| 108 |
+
LoginHistory.event_type == "login_failed",
|
| 109 |
+
LoginHistory.created_at >= cutoff,
|
| 110 |
+
)
|
| 111 |
+
.count()
|
| 112 |
+
)
|
| 113 |
+
return count >= LOGIN_ACCOUNT_MAX_FAILURES
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _login_event_role(db: Session, user_id: Optional[int]) -> str:
|
| 117 |
+
"""Best-effort role lookup for the audit trail β mirrors the fallback in
|
| 118 |
+
app/dda/dda_auth.py::get_user_role, but avoids importing that module here
|
| 119 |
+
(it imports back from this one)."""
|
| 120 |
+
if not user_id:
|
| 121 |
+
return ""
|
| 122 |
+
try:
|
| 123 |
+
from sqlalchemy import text as sa_text
|
| 124 |
+
row = db.execute(sa_text("SELECT role FROM users WHERE id = :uid"), {"uid": user_id}).fetchone()
|
| 125 |
+
return str(row[0]) if row and row[0] else ""
|
| 126 |
+
except Exception:
|
| 127 |
+
return ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def record_login_event(
|
| 131 |
+
db: Session,
|
| 132 |
+
*,
|
| 133 |
+
event_type: str,
|
| 134 |
+
attempted_email: str,
|
| 135 |
+
user_id: Optional[int] = None,
|
| 136 |
+
failure_reason: Optional[str] = None,
|
| 137 |
+
ip_address: str = "",
|
| 138 |
+
user_agent: str = "",
|
| 139 |
+
) -> None:
|
| 140 |
+
entry = LoginHistory(
|
| 141 |
+
user_id=user_id,
|
| 142 |
+
attempted_email=attempted_email,
|
| 143 |
+
event_type=event_type,
|
| 144 |
+
failure_reason=failure_reason,
|
| 145 |
+
ip_address=ip_address,
|
| 146 |
+
user_agent=user_agent,
|
| 147 |
+
)
|
| 148 |
+
db.add(entry)
|
| 149 |
+
try:
|
| 150 |
+
from .dda.tree.audit_service import log_action
|
| 151 |
+
log_action(
|
| 152 |
+
db, event_type,
|
| 153 |
+
action_by=attempted_email,
|
| 154 |
+
role=_login_event_role(db, user_id),
|
| 155 |
+
module="auth",
|
| 156 |
+
new_value={"failure_reason": failure_reason, "ip_address": ip_address} if failure_reason else None,
|
| 157 |
+
)
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
db.commit()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 164 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def get_password_hash(password: str) -> str:
|
| 168 |
+
return pwd_context.hash(password)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 172 |
+
to_encode = data.copy()
|
| 173 |
+
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
| 174 |
+
to_encode.update({"exp": expire})
|
| 175 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES = 30
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _hash_reset_token(raw_token: str) -> str:
|
| 182 |
+
return hashlib.sha256(raw_token.encode()).hexdigest()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def create_password_reset_token(db: Session, user: User) -> str:
|
| 186 |
+
"""Issue a fresh one-time reset token for `user`, invalidating any
|
| 187 |
+
previous unused ones (so an old, possibly-leaked link can't still work
|
| 188 |
+
once a new reset was requested). Returns the raw token β only the caller
|
| 189 |
+
(the emailed link) ever sees it; the DB only keeps its hash."""
|
| 190 |
+
db.query(PasswordResetToken).filter(
|
| 191 |
+
PasswordResetToken.user_id == user.id,
|
| 192 |
+
PasswordResetToken.used_at.is_(None),
|
| 193 |
+
).delete(synchronize_session=False)
|
| 194 |
+
|
| 195 |
+
raw_token = secrets.token_urlsafe(32)
|
| 196 |
+
db.add(PasswordResetToken(
|
| 197 |
+
user_id=user.id,
|
| 198 |
+
token_hash=_hash_reset_token(raw_token),
|
| 199 |
+
expires_at=datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_EXPIRE_MINUTES),
|
| 200 |
+
))
|
| 201 |
+
db.commit()
|
| 202 |
+
return raw_token
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def consume_password_reset_token(db: Session, raw_token: str) -> Optional[User]:
|
| 206 |
+
"""Validate + burn a reset token in one step (single use). The expiry
|
| 207 |
+
comparison is done in the SQL filter, not in Python, so it's never
|
| 208 |
+
exposed to naive/aware datetime mismatches from what the DB driver hands
|
| 209 |
+
back for a stored DATETIME column."""
|
| 210 |
+
if not raw_token:
|
| 211 |
+
return None
|
| 212 |
+
now = datetime.now(timezone.utc)
|
| 213 |
+
entry = (
|
| 214 |
+
db.query(PasswordResetToken)
|
| 215 |
+
.filter(
|
| 216 |
+
PasswordResetToken.token_hash == _hash_reset_token(raw_token),
|
| 217 |
+
PasswordResetToken.used_at.is_(None),
|
| 218 |
+
PasswordResetToken.expires_at >= now,
|
| 219 |
+
)
|
| 220 |
+
.first()
|
| 221 |
+
)
|
| 222 |
+
if not entry:
|
| 223 |
+
return None
|
| 224 |
+
entry.used_at = now
|
| 225 |
+
db.commit()
|
| 226 |
+
return get_user_by_id(db, entry.user_id)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
| 230 |
+
"""Case-insensitive email lookup with whitespace stripped.
|
| 231 |
+
|
| 232 |
+
Emails are supposed to be stored lowercase (see register()), but a) users
|
| 233 |
+
typing them in login forms may leave stray whitespace or capitalisation,
|
| 234 |
+
and b) older accounts predating the .lower() normalisation may still be
|
| 235 |
+
mixed-case. Using LOWER()/TRIM() on both sides avoids the case where the
|
| 236 |
+
same email is treated as "already exists" by one route and "not found" by
|
| 237 |
+
another.
|
| 238 |
+
"""
|
| 239 |
+
if not email:
|
| 240 |
+
return None
|
| 241 |
+
from sqlalchemy import func
|
| 242 |
+
normalised = email.strip().lower()
|
| 243 |
+
return (
|
| 244 |
+
db.query(User)
|
| 245 |
+
.filter(func.lower(func.trim(User.email)) == normalised)
|
| 246 |
+
.first()
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def get_user_by_phone(db: Session, phone: str) -> Optional[User]:
|
| 251 |
+
if not phone:
|
| 252 |
+
return None
|
| 253 |
+
return db.query(User).filter(User.phone == phone).first()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
| 257 |
+
return db.query(User).filter(User.id == user_id).first()
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def normalize_phone(raw: str, country_code: Optional[str] = None) -> Optional[str]:
|
| 261 |
+
"""Return the number as E.164 ("+CCXXXXXXXX"), or None if invalid.
|
| 262 |
+
|
| 263 |
+
Accepts either a raw string that already starts with '+', or a local
|
| 264 |
+
number plus a separate country dial code. India-specific rules (mobile
|
| 265 |
+
prefix must be 6-9, exactly 10 digits) are applied when the country
|
| 266 |
+
dial code resolves to '+91'; every other country is checked only for
|
| 267 |
+
total length (7β15 digits per the E.164 spec).
|
| 268 |
+
"""
|
| 269 |
+
raw = (raw or "").strip()
|
| 270 |
+
cc = (country_code or "").strip()
|
| 271 |
+
if raw.startswith("+"):
|
| 272 |
+
digits = re.sub(r"\D", "", raw)
|
| 273 |
+
e164_country = ""
|
| 274 |
+
if not cc:
|
| 275 |
+
for dial in ("91", "1", "44", "61", "971", "65", "880", "94", "977", "92"):
|
| 276 |
+
if digits.startswith(dial):
|
| 277 |
+
e164_country = "+" + dial
|
| 278 |
+
break
|
| 279 |
+
else:
|
| 280 |
+
e164_country = "+" + re.sub(r"\D", "", cc)
|
| 281 |
+
if not digits.startswith(e164_country[1:]):
|
| 282 |
+
return None
|
| 283 |
+
local = digits[len(e164_country) - 1:] if e164_country else digits
|
| 284 |
+
else:
|
| 285 |
+
if not cc:
|
| 286 |
+
cc = "+91"
|
| 287 |
+
e164_country = "+" + re.sub(r"\D", "", cc)
|
| 288 |
+
local = re.sub(r"\D", "", raw)
|
| 289 |
+
if local.startswith(e164_country[1:]) and len(local) > 10:
|
| 290 |
+
local = local[len(e164_country) - 1:]
|
| 291 |
+
if not e164_country.startswith("+") or len(e164_country) < 2:
|
| 292 |
+
return None
|
| 293 |
+
if not local.isdigit():
|
| 294 |
+
return None
|
| 295 |
+
total = e164_country[1:] + local
|
| 296 |
+
if not (7 <= len(total) <= 15):
|
| 297 |
+
return None
|
| 298 |
+
if e164_country == "+91" and (len(local) != 10 or local[0] not in "6789"):
|
| 299 |
+
return None
|
| 300 |
+
return e164_country + local
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def mask_phone(phone: str) -> str:
|
| 304 |
+
"""Mask an E.164 number for display: '+91******3210'."""
|
| 305 |
+
p = (phone or "").strip()
|
| 306 |
+
if not p:
|
| 307 |
+
return "******"
|
| 308 |
+
if p.startswith("+"):
|
| 309 |
+
digits = re.sub(r"\D", "", p)
|
| 310 |
+
if len(digits) <= 4:
|
| 311 |
+
return "+" + digits
|
| 312 |
+
for dial in ("91", "1", "44", "61", "971", "65", "880", "94", "977", "92"):
|
| 313 |
+
if digits.startswith(dial) and len(digits) > len(dial) + 3:
|
| 314 |
+
local = digits[len(dial):]
|
| 315 |
+
return f"+{dial}{'*' * max(0, len(local) - 4)}{local[-4:]}"
|
| 316 |
+
return "+" + "*" * max(0, len(digits) - 4) + digits[-4:]
|
| 317 |
+
digits = re.sub(r"\D", "", p)
|
| 318 |
+
if len(digits) < 4:
|
| 319 |
+
return "******"
|
| 320 |
+
return f"******{digits[-4:]}"
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def get_user_from_token(token: str, db: Session, *, allow_pending_otp: bool = False) -> Optional[User]:
|
| 324 |
+
"""Resolve user from JWT token.
|
| 325 |
+
|
| 326 |
+
Tokens issued after password-check, before SMS OTP, carry
|
| 327 |
+
purpose=otp_pending and must not grant a session by themselves.
|
| 328 |
+
"""
|
| 329 |
+
if not token:
|
| 330 |
+
return None
|
| 331 |
+
try:
|
| 332 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 333 |
+
purpose = payload.get("purpose") or "session"
|
| 334 |
+
if purpose == "otp_pending" and not allow_pending_otp:
|
| 335 |
+
return None
|
| 336 |
+
if purpose not in ("session", "otp_pending"):
|
| 337 |
+
return None
|
| 338 |
+
user_id_str = payload.get("sub")
|
| 339 |
+
if user_id_str is None:
|
| 340 |
+
return None
|
| 341 |
+
try:
|
| 342 |
+
user_id = int(user_id_str)
|
| 343 |
+
except (ValueError, TypeError):
|
| 344 |
+
logger.warning("JWT 'sub' claim is not a valid integer")
|
| 345 |
+
return None
|
| 346 |
+
except JWTError:
|
| 347 |
+
return None
|
| 348 |
+
return get_user_by_id(db, user_id)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
LOGIN_OTP_EXPIRE_MINUTES = 10
|
| 352 |
+
LOGIN_OTP_MAX_ATTEMPTS = 5
|
| 353 |
+
LOGIN_OTP_RESEND_SECONDS = 45
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _hash_otp(code: str) -> str:
|
| 357 |
+
return hashlib.sha256(code.encode()).hexdigest()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def mask_email(email: str) -> str:
|
| 361 |
+
local, _, domain = (email or "").partition("@")
|
| 362 |
+
if not domain:
|
| 363 |
+
return "***"
|
| 364 |
+
shown = (local[:1] + "***") if len(local) <= 2 else (local[:2] + "***")
|
| 365 |
+
return f"{shown}@{domain}"
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def create_login_otp(db: Session, user: User) -> Tuple[LoginOtp, str]:
|
| 369 |
+
"""Issue a fresh 6-digit email OTP, invalidating unused ones for this user."""
|
| 370 |
+
db.query(LoginOtp).filter(
|
| 371 |
+
LoginOtp.user_id == user.id,
|
| 372 |
+
LoginOtp.used_at.is_(None),
|
| 373 |
+
).delete(synchronize_session=False)
|
| 374 |
+
raw = f"{secrets.randbelow(1_000_000):06d}"
|
| 375 |
+
row = LoginOtp(
|
| 376 |
+
user_id=user.id,
|
| 377 |
+
code_hash=_hash_otp(raw),
|
| 378 |
+
expires_at=datetime.now(timezone.utc) + timedelta(minutes=LOGIN_OTP_EXPIRE_MINUTES),
|
| 379 |
+
attempts=0,
|
| 380 |
+
)
|
| 381 |
+
db.add(row)
|
| 382 |
+
db.commit()
|
| 383 |
+
db.refresh(row)
|
| 384 |
+
return row, raw
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def latest_unused_otp(db: Session, user_id: int) -> Optional[LoginOtp]:
|
| 388 |
+
return (
|
| 389 |
+
db.query(LoginOtp)
|
| 390 |
+
.filter(LoginOtp.user_id == user_id, LoginOtp.used_at.is_(None))
|
| 391 |
+
.order_by(LoginOtp.id.desc())
|
| 392 |
+
.first()
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def verify_login_otp(db: Session, user: User, otp_id: int, code: str) -> str:
|
| 397 |
+
"""Return '' if valid; otherwise a short error token for the caller to map."""
|
| 398 |
+
cleaned = (code or "").strip().replace(" ", "")
|
| 399 |
+
now = datetime.now(timezone.utc)
|
| 400 |
+
row = (
|
| 401 |
+
db.query(LoginOtp)
|
| 402 |
+
.filter(
|
| 403 |
+
LoginOtp.id == otp_id,
|
| 404 |
+
LoginOtp.user_id == user.id,
|
| 405 |
+
LoginOtp.used_at.is_(None),
|
| 406 |
+
)
|
| 407 |
+
.first()
|
| 408 |
+
)
|
| 409 |
+
if not row:
|
| 410 |
+
return "expired"
|
| 411 |
+
exp = row.expires_at
|
| 412 |
+
if exp.tzinfo is None:
|
| 413 |
+
exp = exp.replace(tzinfo=timezone.utc)
|
| 414 |
+
if exp < now:
|
| 415 |
+
return "expired"
|
| 416 |
+
if (row.attempts or 0) >= LOGIN_OTP_MAX_ATTEMPTS:
|
| 417 |
+
return "locked"
|
| 418 |
+
if not cleaned.isdigit() or len(cleaned) != 6 or row.code_hash != _hash_otp(cleaned):
|
| 419 |
+
row.attempts = (row.attempts or 0) + 1
|
| 420 |
+
db.commit()
|
| 421 |
+
return "invalid"
|
| 422 |
+
row.used_at = now
|
| 423 |
+
db.commit()
|
| 424 |
+
return ""
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def get_or_create_guest_user(db: Session) -> User:
|
| 428 |
+
"""Shared anonymous account when login is disabled."""
|
| 429 |
+
guest_email = "__guest__@system.local"
|
| 430 |
+
user = get_user_by_email(db, guest_email)
|
| 431 |
+
if user:
|
| 432 |
+
return user
|
| 433 |
+
user = User(
|
| 434 |
+
email=guest_email,
|
| 435 |
+
hashed_password=get_password_hash("guest-not-used"),
|
| 436 |
+
full_name="Guest",
|
| 437 |
+
)
|
| 438 |
+
db.add(user)
|
| 439 |
+
db.commit()
|
| 440 |
+
db.refresh(user)
|
| 441 |
+
return user
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def get_current_user(
|
| 445 |
+
request: Request,
|
| 446 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 447 |
+
db: Session = Depends(get_db),
|
| 448 |
+
) -> Optional[User]:
|
| 449 |
+
if credentials:
|
| 450 |
+
user = get_user_from_token(credentials.credentials, db)
|
| 451 |
+
if user:
|
| 452 |
+
return user
|
| 453 |
+
token = request.cookies.get(COOKIE_NAME)
|
| 454 |
+
if token:
|
| 455 |
+
return get_user_from_token(token, db)
|
| 456 |
+
return None
|
app/cd_models/__init__.py
ADDED
|
File without changes
|
app/cd_models/bit_cd.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BIT_CD: Bitemporal Image Transformer for change detection (vendored).
|
| 2 |
+
|
| 3 |
+
Self-contained port of the ``base_transformer_pos_s4_dd8_dedim8`` network from
|
| 4 |
+
https://github.com/justchenhao/BIT_CD (Chen et al., "Remote Sensing Image
|
| 5 |
+
Change Detection with Transformers", TGRS 2021), rewritten without the einops
|
| 6 |
+
dependency and using the torchvision ResNet18 backbone.
|
| 7 |
+
|
| 8 |
+
Weights are NOT bundled. Place a LEVIR-CD checkpoint at
|
| 9 |
+
``app/cd_models/weights/bit_cd_levir.pth`` (or point ``BIT_CD_WEIGHTS`` at it).
|
| 10 |
+
Both the raw training checkpoint (``model_G_state_dict`` key, from the
|
| 11 |
+
official repo's best_ckpt.pt) and a plain converted state_dict are accepted.
|
| 12 |
+
|
| 13 |
+
Used by ``model_inference`` as an optional second model ensembled with
|
| 14 |
+
AdaptFormer when ``DETECTION_ENSEMBLE=on``.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
_BIT_MODEL = None
|
| 28 |
+
_BIT_DEVICE = None
|
| 29 |
+
_BIT_FAILED = False
|
| 30 |
+
|
| 31 |
+
WEIGHTS_DIR = Path(__file__).resolve().parent / "weights"
|
| 32 |
+
DEFAULT_WEIGHTS = WEIGHTS_DIR / "bit_cd_levir.pth"
|
| 33 |
+
TILE_SIZE = 256 # LEVIR-CD native patch size
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def weights_path() -> Path:
|
| 37 |
+
env = os.environ.get("BIT_CD_WEIGHTS", "").strip()
|
| 38 |
+
return Path(env) if env else DEFAULT_WEIGHTS
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def weights_available() -> bool:
|
| 42 |
+
p = weights_path()
|
| 43 |
+
return p.is_file() and p.stat().st_size > 1_000_000
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _build_modules():
|
| 47 |
+
import torch
|
| 48 |
+
from torch import nn
|
| 49 |
+
|
| 50 |
+
class TwoLayerConv2d(nn.Sequential):
|
| 51 |
+
def __init__(self, in_channels, out_channels, kernel_size=3):
|
| 52 |
+
super().__init__(
|
| 53 |
+
nn.Conv2d(in_channels, in_channels, kernel_size=kernel_size,
|
| 54 |
+
padding=kernel_size // 2, stride=1, bias=False),
|
| 55 |
+
nn.BatchNorm2d(in_channels),
|
| 56 |
+
nn.ReLU(),
|
| 57 |
+
nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size,
|
| 58 |
+
padding=kernel_size // 2, stride=1),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
class Residual(nn.Module):
|
| 62 |
+
def __init__(self, fn):
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.fn = fn
|
| 65 |
+
|
| 66 |
+
def forward(self, x, **kw):
|
| 67 |
+
return self.fn(x, **kw) + x
|
| 68 |
+
|
| 69 |
+
class Residual2(nn.Module):
|
| 70 |
+
def __init__(self, fn):
|
| 71 |
+
super().__init__()
|
| 72 |
+
self.fn = fn
|
| 73 |
+
|
| 74 |
+
def forward(self, x, m, **kw):
|
| 75 |
+
return self.fn(x, m, **kw) + x
|
| 76 |
+
|
| 77 |
+
class PreNorm(nn.Module):
|
| 78 |
+
def __init__(self, dim, fn):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.norm = nn.LayerNorm(dim)
|
| 81 |
+
self.fn = fn
|
| 82 |
+
|
| 83 |
+
def forward(self, x, **kw):
|
| 84 |
+
return self.fn(self.norm(x), **kw)
|
| 85 |
+
|
| 86 |
+
class PreNorm2(nn.Module):
|
| 87 |
+
def __init__(self, dim, fn):
|
| 88 |
+
super().__init__()
|
| 89 |
+
self.norm = nn.LayerNorm(dim)
|
| 90 |
+
self.fn = fn
|
| 91 |
+
|
| 92 |
+
def forward(self, x, m, **kw):
|
| 93 |
+
return self.fn(self.norm(x), self.norm(m), **kw)
|
| 94 |
+
|
| 95 |
+
class FeedForward(nn.Module):
|
| 96 |
+
def __init__(self, dim, hidden_dim):
|
| 97 |
+
super().__init__()
|
| 98 |
+
self.net = nn.Sequential(
|
| 99 |
+
nn.Linear(dim, hidden_dim), nn.GELU(), nn.Dropout(0.0),
|
| 100 |
+
nn.Linear(hidden_dim, dim), nn.Dropout(0.0),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
def forward(self, x):
|
| 104 |
+
return self.net(x)
|
| 105 |
+
|
| 106 |
+
def _split_heads(t, heads):
|
| 107 |
+
b, n, hd = t.shape
|
| 108 |
+
d = hd // heads
|
| 109 |
+
return t.view(b, n, heads, d).permute(0, 2, 1, 3)
|
| 110 |
+
|
| 111 |
+
def _merge_heads(t):
|
| 112 |
+
b, h, n, d = t.shape
|
| 113 |
+
return t.permute(0, 2, 1, 3).reshape(b, n, h * d)
|
| 114 |
+
|
| 115 |
+
class Attention(nn.Module):
|
| 116 |
+
def __init__(self, dim, heads=8, dim_head=64):
|
| 117 |
+
super().__init__()
|
| 118 |
+
inner = dim_head * heads
|
| 119 |
+
self.heads = heads
|
| 120 |
+
self.scale = dim ** -0.5
|
| 121 |
+
self.to_qkv = nn.Linear(dim, inner * 3, bias=False)
|
| 122 |
+
self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0))
|
| 123 |
+
|
| 124 |
+
def forward(self, x):
|
| 125 |
+
q, k, v = self.to_qkv(x).chunk(3, dim=-1)
|
| 126 |
+
q, k, v = (_split_heads(t, self.heads) for t in (q, k, v))
|
| 127 |
+
attn = (q @ k.transpose(-1, -2) * self.scale).softmax(dim=-1)
|
| 128 |
+
return self.to_out(_merge_heads(attn @ v))
|
| 129 |
+
|
| 130 |
+
class CrossAttention(nn.Module):
|
| 131 |
+
def __init__(self, dim, heads=8, dim_head=64, softmax=True):
|
| 132 |
+
super().__init__()
|
| 133 |
+
inner = dim_head * heads
|
| 134 |
+
self.heads = heads
|
| 135 |
+
self.scale = dim ** -0.5
|
| 136 |
+
self.softmax = softmax
|
| 137 |
+
self.to_q = nn.Linear(dim, inner, bias=False)
|
| 138 |
+
self.to_k = nn.Linear(dim, inner, bias=False)
|
| 139 |
+
self.to_v = nn.Linear(dim, inner, bias=False)
|
| 140 |
+
self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0))
|
| 141 |
+
|
| 142 |
+
def forward(self, x, m):
|
| 143 |
+
q = _split_heads(self.to_q(x), self.heads)
|
| 144 |
+
k = _split_heads(self.to_k(m), self.heads)
|
| 145 |
+
v = _split_heads(self.to_v(m), self.heads)
|
| 146 |
+
dots = q @ k.transpose(-1, -2) * self.scale
|
| 147 |
+
attn = dots.softmax(dim=-1) if self.softmax else dots
|
| 148 |
+
return self.to_out(_merge_heads(attn @ v))
|
| 149 |
+
|
| 150 |
+
class Transformer(nn.Module):
|
| 151 |
+
def __init__(self, dim, depth, heads, dim_head, mlp_dim):
|
| 152 |
+
super().__init__()
|
| 153 |
+
self.layers = nn.ModuleList([
|
| 154 |
+
nn.ModuleList([
|
| 155 |
+
Residual(PreNorm(dim, Attention(dim, heads, dim_head))),
|
| 156 |
+
Residual(PreNorm(dim, FeedForward(dim, mlp_dim))),
|
| 157 |
+
]) for _ in range(depth)
|
| 158 |
+
])
|
| 159 |
+
|
| 160 |
+
def forward(self, x):
|
| 161 |
+
for attn, ff in self.layers:
|
| 162 |
+
x = attn(x)
|
| 163 |
+
x = ff(x)
|
| 164 |
+
return x
|
| 165 |
+
|
| 166 |
+
class TransformerDecoder(nn.Module):
|
| 167 |
+
def __init__(self, dim, depth, heads, dim_head, mlp_dim, softmax=True):
|
| 168 |
+
super().__init__()
|
| 169 |
+
self.layers = nn.ModuleList([
|
| 170 |
+
nn.ModuleList([
|
| 171 |
+
Residual2(PreNorm2(dim, CrossAttention(dim, heads, dim_head, softmax))),
|
| 172 |
+
Residual(PreNorm(dim, FeedForward(dim, mlp_dim))),
|
| 173 |
+
]) for _ in range(depth)
|
| 174 |
+
])
|
| 175 |
+
|
| 176 |
+
def forward(self, x, m):
|
| 177 |
+
for attn, ff in self.layers:
|
| 178 |
+
x = attn(x, m)
|
| 179 |
+
x = ff(x)
|
| 180 |
+
return x
|
| 181 |
+
|
| 182 |
+
class DilatedBasicBlock(nn.Module):
|
| 183 |
+
"""torchvision BasicBlock with dilation allowed (as in BIT_CD's resnet)."""
|
| 184 |
+
expansion = 1
|
| 185 |
+
|
| 186 |
+
def __init__(self, inplanes, planes, stride=1, downsample=None, dilation=1):
|
| 187 |
+
super().__init__()
|
| 188 |
+
self.conv1 = nn.Conv2d(inplanes, planes, 3, stride=stride,
|
| 189 |
+
padding=dilation, dilation=dilation, bias=False)
|
| 190 |
+
self.bn1 = nn.BatchNorm2d(planes)
|
| 191 |
+
self.relu = nn.ReLU(inplace=True)
|
| 192 |
+
self.conv2 = nn.Conv2d(planes, planes, 3, padding=dilation,
|
| 193 |
+
dilation=dilation, bias=False)
|
| 194 |
+
self.bn2 = nn.BatchNorm2d(planes)
|
| 195 |
+
self.downsample = downsample
|
| 196 |
+
|
| 197 |
+
def forward(self, x):
|
| 198 |
+
identity = x
|
| 199 |
+
out = self.relu(self.bn1(self.conv1(x)))
|
| 200 |
+
out = self.bn2(self.conv2(out))
|
| 201 |
+
if self.downsample is not None:
|
| 202 |
+
identity = self.downsample(x)
|
| 203 |
+
return self.relu(out + identity)
|
| 204 |
+
|
| 205 |
+
class DilatedResNet18(nn.Module):
|
| 206 |
+
"""ResNet18 trunk with replace_stride_with_dilation=[False, True, True].
|
| 207 |
+
|
| 208 |
+
Same module naming as torchvision so BIT_CD checkpoints load directly.
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
def __init__(self):
|
| 212 |
+
super().__init__()
|
| 213 |
+
self.inplanes = 64
|
| 214 |
+
self.dilation = 1
|
| 215 |
+
self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False)
|
| 216 |
+
self.bn1 = nn.BatchNorm2d(64)
|
| 217 |
+
self.relu = nn.ReLU(inplace=True)
|
| 218 |
+
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
|
| 219 |
+
# replace_stride_with_dilation=[False, True, True]: layer2 keeps
|
| 220 |
+
# stride 2; layers 3-4 trade stride for dilation.
|
| 221 |
+
self.layer1 = self._make_layer(64, 2, stride=1, dilate=False)
|
| 222 |
+
self.layer2 = self._make_layer(128, 2, stride=2, dilate=False)
|
| 223 |
+
self.layer3 = self._make_layer(256, 2, stride=2, dilate=True)
|
| 224 |
+
self.layer4 = self._make_layer(512, 2, stride=2, dilate=True)
|
| 225 |
+
|
| 226 |
+
def _make_layer(self, planes, blocks, stride, dilate):
|
| 227 |
+
downsample = None
|
| 228 |
+
previous_dilation = self.dilation
|
| 229 |
+
if dilate:
|
| 230 |
+
self.dilation *= stride
|
| 231 |
+
stride = 1
|
| 232 |
+
if stride != 1 or self.inplanes != planes:
|
| 233 |
+
downsample = nn.Sequential(
|
| 234 |
+
nn.Conv2d(self.inplanes, planes, 1, stride=stride, bias=False),
|
| 235 |
+
nn.BatchNorm2d(planes),
|
| 236 |
+
)
|
| 237 |
+
layers = [DilatedBasicBlock(self.inplanes, planes, stride,
|
| 238 |
+
downsample, previous_dilation)]
|
| 239 |
+
self.inplanes = planes
|
| 240 |
+
for _ in range(1, blocks):
|
| 241 |
+
layers.append(DilatedBasicBlock(planes, planes, dilation=self.dilation))
|
| 242 |
+
return nn.Sequential(*layers)
|
| 243 |
+
|
| 244 |
+
class BITTransformer(nn.Module):
|
| 245 |
+
"""ResNet18 (4 stages, dilated) + token transformer + difference head."""
|
| 246 |
+
|
| 247 |
+
def __init__(self, output_nc=2, token_len=4, enc_depth=1, dec_depth=8,
|
| 248 |
+
dim_head=64, decoder_dim_head=8):
|
| 249 |
+
super().__init__()
|
| 250 |
+
self.resnet = DilatedResNet18()
|
| 251 |
+
self.relu = nn.ReLU()
|
| 252 |
+
self.upsamplex2 = nn.Upsample(scale_factor=2)
|
| 253 |
+
self.upsamplex4 = nn.Upsample(scale_factor=4, mode="bilinear")
|
| 254 |
+
self.classifier = TwoLayerConv2d(in_channels=32, out_channels=output_nc)
|
| 255 |
+
self.conv_pred = nn.Conv2d(256, 32, kernel_size=3, padding=1)
|
| 256 |
+
|
| 257 |
+
self.token_len = token_len
|
| 258 |
+
dim = 32
|
| 259 |
+
self.conv_a = nn.Conv2d(dim, token_len, kernel_size=1, padding=0, bias=False)
|
| 260 |
+
self.pos_embedding = nn.Parameter(torch.randn(1, token_len * 2, dim))
|
| 261 |
+
self.transformer = Transformer(dim, enc_depth, 8, dim_head, 2 * dim)
|
| 262 |
+
self.transformer_decoder = TransformerDecoder(
|
| 263 |
+
dim, dec_depth, 8, decoder_dim_head, 2 * dim, softmax=True)
|
| 264 |
+
|
| 265 |
+
def forward_single(self, x):
|
| 266 |
+
x = self.resnet.conv1(x)
|
| 267 |
+
x = self.resnet.bn1(x)
|
| 268 |
+
x = self.resnet.relu(x)
|
| 269 |
+
x = self.resnet.maxpool(x)
|
| 270 |
+
x = self.resnet.layer1(x)
|
| 271 |
+
x = self.resnet.layer2(x)
|
| 272 |
+
x = self.resnet.layer3(x) # stages_num=4 -> stop before layer4
|
| 273 |
+
x = self.upsamplex2(x)
|
| 274 |
+
return self.conv_pred(x)
|
| 275 |
+
|
| 276 |
+
def _semantic_tokens(self, x):
|
| 277 |
+
b, c, h, w = x.shape
|
| 278 |
+
att = self.conv_a(x).view(b, self.token_len, -1).softmax(dim=-1)
|
| 279 |
+
flat = x.view(b, c, -1)
|
| 280 |
+
return torch.einsum("bln,bcn->blc", att, flat)
|
| 281 |
+
|
| 282 |
+
def _decode(self, x, m):
|
| 283 |
+
b, c, h, w = x.shape
|
| 284 |
+
seq = x.flatten(2).transpose(1, 2)
|
| 285 |
+
seq = self.transformer_decoder(seq, m)
|
| 286 |
+
return seq.transpose(1, 2).reshape(b, c, h, w)
|
| 287 |
+
|
| 288 |
+
def forward(self, x1, x2):
|
| 289 |
+
x1 = self.forward_single(x1)
|
| 290 |
+
x2 = self.forward_single(x2)
|
| 291 |
+
t1 = self._semantic_tokens(x1)
|
| 292 |
+
t2 = self._semantic_tokens(x2)
|
| 293 |
+
tokens = torch.cat([t1, t2], dim=1) + self.pos_embedding
|
| 294 |
+
tokens = self.transformer(tokens)
|
| 295 |
+
t1, t2 = tokens.chunk(2, dim=1)
|
| 296 |
+
x1 = self._decode(x1, t1)
|
| 297 |
+
x2 = self._decode(x2, t2)
|
| 298 |
+
x = torch.abs(x1 - x2)
|
| 299 |
+
x = self.upsamplex4(x)
|
| 300 |
+
return self.classifier(x)
|
| 301 |
+
|
| 302 |
+
return BITTransformer
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _extract_state_dict(checkpoint) -> dict:
|
| 306 |
+
if isinstance(checkpoint, dict):
|
| 307 |
+
for key in ("model_G_state_dict", "state_dict", "model_state_dict"):
|
| 308 |
+
if key in checkpoint and isinstance(checkpoint[key], dict):
|
| 309 |
+
checkpoint = checkpoint[key]
|
| 310 |
+
break
|
| 311 |
+
return {k[7:] if k.startswith("module.") else k: v for k, v in checkpoint.items()}
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def load_bit_model():
|
| 315 |
+
"""Load BIT_CD with LEVIR weights. Returns None when unavailable."""
|
| 316 |
+
global _BIT_MODEL, _BIT_DEVICE, _BIT_FAILED
|
| 317 |
+
if _BIT_MODEL is not None:
|
| 318 |
+
return _BIT_MODEL
|
| 319 |
+
if _BIT_FAILED or not weights_available():
|
| 320 |
+
return None
|
| 321 |
+
try:
|
| 322 |
+
import torch
|
| 323 |
+
|
| 324 |
+
BITTransformer = _build_modules()
|
| 325 |
+
model = BITTransformer()
|
| 326 |
+
checkpoint = torch.load(weights_path(), map_location="cpu", weights_only=False)
|
| 327 |
+
state = _extract_state_dict(checkpoint)
|
| 328 |
+
missing, unexpected = model.load_state_dict(state, strict=False)
|
| 329 |
+
real_missing = [k for k in missing if "num_batches_tracked" not in k]
|
| 330 |
+
if len(real_missing) > 20:
|
| 331 |
+
raise RuntimeError(
|
| 332 |
+
f"checkpoint mismatch: {len(real_missing)} missing keys "
|
| 333 |
+
f"(e.g. {real_missing[:4]}), {len(unexpected)} unexpected")
|
| 334 |
+
if real_missing or unexpected:
|
| 335 |
+
logger.warning("BIT_CD loaded with %d missing / %d unexpected keys",
|
| 336 |
+
len(real_missing), len(unexpected))
|
| 337 |
+
_BIT_DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 338 |
+
model.to(_BIT_DEVICE)
|
| 339 |
+
model.eval()
|
| 340 |
+
_BIT_MODEL = model
|
| 341 |
+
logger.info("BIT_CD loaded from %s on %s", weights_path(), _BIT_DEVICE)
|
| 342 |
+
return _BIT_MODEL
|
| 343 |
+
except Exception as exc:
|
| 344 |
+
_BIT_FAILED = True
|
| 345 |
+
logger.error("BIT_CD load failed: %s", exc)
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def bit_score_map(img1: np.ndarray, img2: np.ndarray) -> Optional[np.ndarray]:
|
| 350 |
+
"""Tiled BIT_CD change probability map in [0,1] at (h, w), or None."""
|
| 351 |
+
model = load_bit_model()
|
| 352 |
+
if model is None:
|
| 353 |
+
return None
|
| 354 |
+
try:
|
| 355 |
+
import torch
|
| 356 |
+
|
| 357 |
+
from .model_utils import tiled_score_map
|
| 358 |
+
|
| 359 |
+
# ImageNet-style normalization matching the BIT_CD training transforms
|
| 360 |
+
mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
| 361 |
+
std = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
| 362 |
+
|
| 363 |
+
def _to_tensor(arr):
|
| 364 |
+
t = (arr.astype(np.float32) / 255.0 - mean) / std
|
| 365 |
+
return torch.from_numpy(t.transpose(2, 0, 1)).unsqueeze(0).to(_BIT_DEVICE)
|
| 366 |
+
|
| 367 |
+
def _score_tile(t1, t2):
|
| 368 |
+
with torch.no_grad():
|
| 369 |
+
logits = model(_to_tensor(t1), _to_tensor(t2))
|
| 370 |
+
prob = torch.softmax(logits, dim=1)[0, 1]
|
| 371 |
+
return prob.cpu().numpy().astype(np.float32)
|
| 372 |
+
|
| 373 |
+
return tiled_score_map(_score_tile, img1, img2,
|
| 374 |
+
tile_size=TILE_SIZE, overlap=TILE_SIZE // 4)
|
| 375 |
+
except Exception as exc:
|
| 376 |
+
logger.warning("BIT_CD scoring failed: %s", exc)
|
| 377 |
+
return None
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def bit_status() -> dict:
|
| 381 |
+
return {
|
| 382 |
+
"weightsPath": str(weights_path()),
|
| 383 |
+
"weightsAvailable": weights_available(),
|
| 384 |
+
"loaded": _BIT_MODEL is not None,
|
| 385 |
+
"loadFailed": _BIT_FAILED,
|
| 386 |
+
}
|
app/cd_models/change_model.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Siamese U-Net for satellite change detection.
|
| 3 |
+
|
| 4 |
+
A lightweight Siamese encoder shares weights between before/after images,
|
| 5 |
+
fuses features via concatenation + difference, and decodes into a binary
|
| 6 |
+
change probability map.
|
| 7 |
+
|
| 8 |
+
Designed for CPU inference (< 2s per 256x256 tile).
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_MODEL = None
|
| 20 |
+
_DEVICE = None
|
| 21 |
+
_AVAILABLE = None
|
| 22 |
+
_WEIGHTS_DIR = Path(__file__).parent / "weights"
|
| 23 |
+
_WEIGHTS_FILE = _WEIGHTS_DIR / "siamese_unet_cd.pt"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _try_torch():
|
| 27 |
+
try:
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
return torch, nn
|
| 31 |
+
except ImportError:
|
| 32 |
+
return None, None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Model architecture
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
def _build_model():
|
| 40 |
+
torch, nn = _try_torch()
|
| 41 |
+
if torch is None:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
class ConvBlock(nn.Module):
|
| 45 |
+
def __init__(self, in_ch, out_ch):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.block = nn.Sequential(
|
| 48 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 49 |
+
nn.BatchNorm2d(out_ch),
|
| 50 |
+
nn.ReLU(inplace=True),
|
| 51 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 52 |
+
nn.BatchNorm2d(out_ch),
|
| 53 |
+
nn.ReLU(inplace=True),
|
| 54 |
+
)
|
| 55 |
+
def forward(self, x):
|
| 56 |
+
return self.block(x)
|
| 57 |
+
|
| 58 |
+
class Encoder(nn.Module):
|
| 59 |
+
def __init__(self, in_ch=3, base=32):
|
| 60 |
+
super().__init__()
|
| 61 |
+
self.enc1 = ConvBlock(in_ch, base)
|
| 62 |
+
self.enc2 = ConvBlock(base, base * 2)
|
| 63 |
+
self.enc3 = ConvBlock(base * 2, base * 4)
|
| 64 |
+
self.enc4 = ConvBlock(base * 4, base * 8)
|
| 65 |
+
self.pool = nn.MaxPool2d(2)
|
| 66 |
+
|
| 67 |
+
def forward(self, x):
|
| 68 |
+
e1 = self.enc1(x)
|
| 69 |
+
e2 = self.enc2(self.pool(e1))
|
| 70 |
+
e3 = self.enc3(self.pool(e2))
|
| 71 |
+
e4 = self.enc4(self.pool(e3))
|
| 72 |
+
return [e1, e2, e3, e4]
|
| 73 |
+
|
| 74 |
+
class SiameseUNet(nn.Module):
|
| 75 |
+
"""
|
| 76 |
+
Siamese U-Net: shared encoder processes before/after images independently.
|
| 77 |
+
Decoder fuses features via concatenation of both streams + their absolute
|
| 78 |
+
difference, providing the decoder with explicit change information.
|
| 79 |
+
"""
|
| 80 |
+
def __init__(self, in_ch=3, base=32, out_ch=2):
|
| 81 |
+
super().__init__()
|
| 82 |
+
self.encoder = Encoder(in_ch, base)
|
| 83 |
+
b = base
|
| 84 |
+
|
| 85 |
+
# Decoder: at each level receives [enc_a, enc_b, |enc_a-enc_b|] = 3x channels
|
| 86 |
+
self.up4 = nn.ConvTranspose2d(b * 8, b * 4, 2, stride=2)
|
| 87 |
+
self.dec4 = ConvBlock(b * 4 + b * 4 * 3, b * 4)
|
| 88 |
+
|
| 89 |
+
self.up3 = nn.ConvTranspose2d(b * 4, b * 2, 2, stride=2)
|
| 90 |
+
self.dec3 = ConvBlock(b * 2 + b * 2 * 3, b * 2)
|
| 91 |
+
|
| 92 |
+
self.up2 = nn.ConvTranspose2d(b * 2, b, 2, stride=2)
|
| 93 |
+
self.dec2 = ConvBlock(b + b * 3, b)
|
| 94 |
+
|
| 95 |
+
self.head = nn.Conv2d(b, out_ch, 1)
|
| 96 |
+
|
| 97 |
+
def forward(self, img_a, img_b):
|
| 98 |
+
feats_a = self.encoder(img_a)
|
| 99 |
+
feats_b = self.encoder(img_b)
|
| 100 |
+
|
| 101 |
+
# Bottleneck: fuse deepest features
|
| 102 |
+
bot = torch.cat([feats_a[3], feats_b[3], torch.abs(feats_a[3] - feats_b[3])], dim=1)
|
| 103 |
+
|
| 104 |
+
import torch.nn.functional as F
|
| 105 |
+
# Level 3
|
| 106 |
+
d4 = self.up4(feats_a[3])
|
| 107 |
+
skip3 = torch.cat([feats_a[2], feats_b[2], torch.abs(feats_a[2] - feats_b[2])], dim=1)
|
| 108 |
+
d4 = self.dec4(torch.cat([d4, skip3], dim=1))
|
| 109 |
+
|
| 110 |
+
# Level 2
|
| 111 |
+
d3 = self.up3(d4)
|
| 112 |
+
skip2 = torch.cat([feats_a[1], feats_b[1], torch.abs(feats_a[1] - feats_b[1])], dim=1)
|
| 113 |
+
d3 = self.dec3(torch.cat([d3, skip2], dim=1))
|
| 114 |
+
|
| 115 |
+
# Level 1
|
| 116 |
+
d2 = self.up2(d3)
|
| 117 |
+
skip1 = torch.cat([feats_a[0], feats_b[0], torch.abs(feats_a[0] - feats_b[0])], dim=1)
|
| 118 |
+
d2 = self.dec2(torch.cat([d2, skip1], dim=1))
|
| 119 |
+
|
| 120 |
+
return self.head(d2)
|
| 121 |
+
|
| 122 |
+
return SiameseUNet
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Model loading (singleton)
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
def has_siamese_weights():
|
| 130 |
+
"""True only when a trained weights file is present."""
|
| 131 |
+
return _WEIGHTS_FILE.is_file()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def is_siamese_available():
|
| 135 |
+
"""PyTorch installed and pretrained weights available."""
|
| 136 |
+
global _AVAILABLE
|
| 137 |
+
if _AVAILABLE is not None:
|
| 138 |
+
return _AVAILABLE
|
| 139 |
+
torch, _ = _try_torch()
|
| 140 |
+
_AVAILABLE = torch is not None and has_siamese_weights()
|
| 141 |
+
return _AVAILABLE
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _load_siamese():
|
| 145 |
+
global _MODEL, _DEVICE
|
| 146 |
+
if _MODEL is not None:
|
| 147 |
+
return _MODEL
|
| 148 |
+
|
| 149 |
+
torch, _ = _try_torch()
|
| 150 |
+
if torch is None:
|
| 151 |
+
raise RuntimeError("PyTorch not installed")
|
| 152 |
+
|
| 153 |
+
_DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 154 |
+
|
| 155 |
+
ModelClass = _build_model()
|
| 156 |
+
model = ModelClass(in_ch=3, base=32, out_ch=2)
|
| 157 |
+
|
| 158 |
+
if _WEIGHTS_FILE.exists():
|
| 159 |
+
logger.info("Loading Siamese U-Net weights from %s", _WEIGHTS_FILE)
|
| 160 |
+
state = torch.load(str(_WEIGHTS_FILE), map_location=_DEVICE, weights_only=True)
|
| 161 |
+
model.load_state_dict(state)
|
| 162 |
+
else:
|
| 163 |
+
logger.info("No pretrained weights found at %s β using random init "
|
| 164 |
+
"(model will still produce change maps but accuracy depends on "
|
| 165 |
+
"classical fusion weighting)", _WEIGHTS_FILE)
|
| 166 |
+
|
| 167 |
+
model.to(_DEVICE)
|
| 168 |
+
model.eval()
|
| 169 |
+
_MODEL = model
|
| 170 |
+
return _MODEL
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
# Inference
|
| 175 |
+
# ---------------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
_TILE = 256
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def predict_siamese(img1, img2, threshold=0.5):
|
| 181 |
+
"""
|
| 182 |
+
Run Siamese U-Net inference on two RGB uint8 arrays.
|
| 183 |
+
Tile-based with overlap stitching (same pattern as AdaptFormer).
|
| 184 |
+
Returns (uint8 mask [0|255], float32 probability map [0-1]).
|
| 185 |
+
"""
|
| 186 |
+
torch, _ = _try_torch()
|
| 187 |
+
model = _load_siamese()
|
| 188 |
+
|
| 189 |
+
if img1.shape != img2.shape:
|
| 190 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 191 |
+
|
| 192 |
+
from .model_utils import tiled_score_map
|
| 193 |
+
|
| 194 |
+
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 195 |
+
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 196 |
+
|
| 197 |
+
def _score_tile(t1, t2):
|
| 198 |
+
n1 = (t1.astype(np.float32) / 255.0 - mean) / std
|
| 199 |
+
n2 = (t2.astype(np.float32) / 255.0 - mean) / std
|
| 200 |
+
ta = torch.from_numpy(n1.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
|
| 201 |
+
tb = torch.from_numpy(n2.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
|
| 202 |
+
logits = model(ta, tb)
|
| 203 |
+
probs = torch.softmax(logits, dim=1)
|
| 204 |
+
return probs[0, 1].cpu().numpy()
|
| 205 |
+
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
avg = tiled_score_map(_score_tile, img1, img2,
|
| 208 |
+
tile_size=_TILE, overlap=_TILE // 4)
|
| 209 |
+
|
| 210 |
+
mask = (avg >= threshold).astype(np.uint8) * 255
|
| 211 |
+
return mask, avg
|
app/cd_models/irmad.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""IR-MAD: Iteratively Reweighted Multivariate Alteration Detection.
|
| 2 |
+
|
| 3 |
+
Classical unsupervised change detection statistic (Nielsen 2007), listed among
|
| 4 |
+
the strongest traditional methods in the Change-Detection-Review repository
|
| 5 |
+
(https://github.com/MinZHANG-WHU/Change-Detection-Review).
|
| 6 |
+
|
| 7 |
+
MAD finds paired linear combinations of the two images' bands (via canonical
|
| 8 |
+
correlation analysis) whose differences maximally decorrelate; the chi-square
|
| 9 |
+
sum of squared standardized MAD variates measures change. The IR step
|
| 10 |
+
re-estimates the statistics using no-change weights so genuine changes don't
|
| 11 |
+
bias the transform, sharpening separation between change and no-change.
|
| 12 |
+
|
| 13 |
+
Pure numpy β no extra dependencies. Runs on a bounded-resolution copy for CPU
|
| 14 |
+
practicality and returns both the change probability map and the final
|
| 15 |
+
no-change weights (useful for regression-based radiometric normalization).
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from typing import Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import cv2
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
_log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class IRMADResult:
|
| 31 |
+
chi2: np.ndarray # float32, raw chi-square statistic (analysis res)
|
| 32 |
+
change_prob: np.ndarray # float32 [0,1], at the input resolution
|
| 33 |
+
no_change_weights: np.ndarray # float32 [0,1], at the input resolution
|
| 34 |
+
iterations: int
|
| 35 |
+
converged: bool
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _weighted_stats(x: np.ndarray, y: np.ndarray, w: np.ndarray):
|
| 39 |
+
"""Weighted means and joint covariance of two multiband pixel matrices."""
|
| 40 |
+
wsum = float(np.sum(w)) + 1e-12
|
| 41 |
+
mx = (x * w[:, None]).sum(axis=0) / wsum
|
| 42 |
+
my = (y * w[:, None]).sum(axis=0) / wsum
|
| 43 |
+
xc = x - mx
|
| 44 |
+
yc = y - my
|
| 45 |
+
sxx = (xc * w[:, None]).T @ xc / wsum
|
| 46 |
+
syy = (yc * w[:, None]).T @ yc / wsum
|
| 47 |
+
sxy = (xc * w[:, None]).T @ yc / wsum
|
| 48 |
+
return mx, my, sxx, syy, sxy, xc, yc
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _chi2_cdf(x: np.ndarray, k: int) -> np.ndarray:
|
| 52 |
+
"""Chi-square CDF via the regularized lower incomplete gamma function."""
|
| 53 |
+
try:
|
| 54 |
+
from scipy.special import gammainc
|
| 55 |
+
return gammainc(k / 2.0, x / 2.0)
|
| 56 |
+
except Exception:
|
| 57 |
+
# WilsonβHilferty normal approximation (good for k>=3)
|
| 58 |
+
z = ((x / k) ** (1.0 / 3.0) - (1 - 2.0 / (9 * k))) / np.sqrt(2.0 / (9 * k))
|
| 59 |
+
return 0.5 * (1.0 + np.tanh(z * 0.79788456 * (1 + 0.044715 * z * z)))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def compute_irmad(
|
| 63 |
+
img1: np.ndarray,
|
| 64 |
+
img2: np.ndarray,
|
| 65 |
+
*,
|
| 66 |
+
max_iters: int = 10,
|
| 67 |
+
tol: float = 1e-3,
|
| 68 |
+
max_side: int = 1024,
|
| 69 |
+
eps: float = 1e-6,
|
| 70 |
+
) -> Optional[IRMADResult]:
|
| 71 |
+
"""Run IR-MAD on an RGB pair. Returns None on numerical failure."""
|
| 72 |
+
try:
|
| 73 |
+
if img1.shape != img2.shape or img1.ndim != 3:
|
| 74 |
+
return None
|
| 75 |
+
full_h, full_w = img1.shape[:2]
|
| 76 |
+
|
| 77 |
+
scale = min(1.0, max_side / max(full_h, full_w))
|
| 78 |
+
if scale < 1.0:
|
| 79 |
+
ah, aw = max(64, int(full_h * scale)), max(64, int(full_w * scale))
|
| 80 |
+
a = cv2.resize(img1, (aw, ah), interpolation=cv2.INTER_AREA)
|
| 81 |
+
b = cv2.resize(img2, (aw, ah), interpolation=cv2.INTER_AREA)
|
| 82 |
+
else:
|
| 83 |
+
ah, aw = full_h, full_w
|
| 84 |
+
a, b = img1, img2
|
| 85 |
+
|
| 86 |
+
bands = a.shape[2]
|
| 87 |
+
x = a.reshape(-1, bands).astype(np.float64)
|
| 88 |
+
y = b.reshape(-1, bands).astype(np.float64)
|
| 89 |
+
n = x.shape[0]
|
| 90 |
+
w = np.ones(n, dtype=np.float64)
|
| 91 |
+
|
| 92 |
+
rho_prev = None
|
| 93 |
+
converged = False
|
| 94 |
+
it = 0
|
| 95 |
+
mads_std = np.zeros_like(x)
|
| 96 |
+
|
| 97 |
+
for it in range(1, max_iters + 1):
|
| 98 |
+
_, _, sxx, syy, sxy, xc, yc = _weighted_stats(x, y, w)
|
| 99 |
+
sxx += eps * np.eye(bands)
|
| 100 |
+
syy += eps * np.eye(bands)
|
| 101 |
+
|
| 102 |
+
# CCA via generalized eigenproblem on sxx^-1 sxy syy^-1 syx
|
| 103 |
+
isxx = np.linalg.inv(sxx)
|
| 104 |
+
isyy = np.linalg.inv(syy)
|
| 105 |
+
m1 = isxx @ sxy @ isyy @ sxy.T
|
| 106 |
+
evals, evecs = np.linalg.eig(m1)
|
| 107 |
+
order = np.argsort(evals.real)[::-1]
|
| 108 |
+
rho2 = np.clip(evals.real[order], 0.0, 1.0)
|
| 109 |
+
avecs = evecs.real[:, order]
|
| 110 |
+
|
| 111 |
+
# Normalize canonical vectors: var(a^T x) = 1
|
| 112 |
+
for j in range(bands):
|
| 113 |
+
va = avecs[:, j] @ sxx @ avecs[:, j]
|
| 114 |
+
avecs[:, j] /= np.sqrt(max(va, eps))
|
| 115 |
+
bvecs = isyy @ sxy.T @ avecs
|
| 116 |
+
for j in range(bands):
|
| 117 |
+
vb = bvecs[:, j] @ syy @ bvecs[:, j]
|
| 118 |
+
bvecs[:, j] /= np.sqrt(max(vb, eps))
|
| 119 |
+
|
| 120 |
+
u = xc @ avecs
|
| 121 |
+
v = yc @ bvecs
|
| 122 |
+
# Sign alignment: positive correlation between pairs
|
| 123 |
+
for j in range(bands):
|
| 124 |
+
if np.sum(u[:, j] * v[:, j] * w) < 0:
|
| 125 |
+
bvecs[:, j] = -bvecs[:, j]
|
| 126 |
+
v[:, j] = -v[:, j]
|
| 127 |
+
|
| 128 |
+
mads = u - v
|
| 129 |
+
rho = np.sqrt(rho2)
|
| 130 |
+
sigma2 = np.maximum(2.0 * (1.0 - rho), eps) # MAD variances
|
| 131 |
+
mads_std = mads / np.sqrt(sigma2)[None, :]
|
| 132 |
+
|
| 133 |
+
chi2 = np.sum(mads_std ** 2, axis=1)
|
| 134 |
+
# No-change probability = 1 - CDF (large chi2 => change)
|
| 135 |
+
w_new = 1.0 - _chi2_cdf(chi2, bands)
|
| 136 |
+
w_new = np.clip(w_new, 1e-6, 1.0)
|
| 137 |
+
|
| 138 |
+
if rho_prev is not None and np.max(np.abs(rho - rho_prev)) < tol:
|
| 139 |
+
w = w_new
|
| 140 |
+
converged = True
|
| 141 |
+
break
|
| 142 |
+
rho_prev = rho
|
| 143 |
+
w = w_new
|
| 144 |
+
|
| 145 |
+
chi2_map = np.sum(mads_std ** 2, axis=1).reshape(ah, aw)
|
| 146 |
+
# Robust quantile normalization instead of the chi-square CDF: the CDF
|
| 147 |
+
# saturates at ~1.0 for most real pixels, which destroys the graded
|
| 148 |
+
# response needed by percentile-threshold fusion downstream.
|
| 149 |
+
hi = float(np.quantile(chi2_map, 0.995))
|
| 150 |
+
if hi <= 1e-8:
|
| 151 |
+
hi = float(chi2_map.max() + 1e-8)
|
| 152 |
+
prob = np.clip(chi2_map / hi, 0.0, 1.0)
|
| 153 |
+
nc_w = w.reshape(ah, aw)
|
| 154 |
+
|
| 155 |
+
prob_full = prob.astype(np.float32)
|
| 156 |
+
ncw_full = nc_w.astype(np.float32)
|
| 157 |
+
if (ah, aw) != (full_h, full_w):
|
| 158 |
+
prob_full = cv2.resize(prob_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
|
| 159 |
+
ncw_full = cv2.resize(ncw_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
|
| 160 |
+
|
| 161 |
+
return IRMADResult(
|
| 162 |
+
chi2=chi2_map.astype(np.float32),
|
| 163 |
+
change_prob=np.clip(prob_full, 0.0, 1.0),
|
| 164 |
+
no_change_weights=np.clip(ncw_full, 0.0, 1.0),
|
| 165 |
+
iterations=it,
|
| 166 |
+
converged=converged,
|
| 167 |
+
)
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
_log.warning("IR-MAD failed (%s)", exc)
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def radiometric_regression_normalize(
|
| 174 |
+
img1: np.ndarray,
|
| 175 |
+
img2: np.ndarray,
|
| 176 |
+
no_change_weights: np.ndarray,
|
| 177 |
+
min_weight: float = 0.5,
|
| 178 |
+
) -> Optional[np.ndarray]:
|
| 179 |
+
"""Normalize img2 to img1 via weighted linear regression on no-change pixels.
|
| 180 |
+
|
| 181 |
+
Classic IR-MAD application: fit per-band ``img1 ~ a*img2 + b`` using only
|
| 182 |
+
pixels the IR-MAD iteration deemed unchanged, then map img2 through the
|
| 183 |
+
fit. More faithful than global mean/std matching because changed pixels
|
| 184 |
+
no longer skew the statistics. Returns uint8 image or None.
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
if img1.shape != img2.shape:
|
| 188 |
+
return None
|
| 189 |
+
w = no_change_weights.astype(np.float64).ravel()
|
| 190 |
+
sel = w >= min_weight
|
| 191 |
+
if np.count_nonzero(sel) < 500:
|
| 192 |
+
return None
|
| 193 |
+
out = img2.astype(np.float64).copy()
|
| 194 |
+
for ch in range(img1.shape[2]):
|
| 195 |
+
s = img2[:, :, ch].astype(np.float64).ravel()[sel]
|
| 196 |
+
t = img1[:, :, ch].astype(np.float64).ravel()[sel]
|
| 197 |
+
ww = w[sel]
|
| 198 |
+
wsum = ww.sum() + 1e-12
|
| 199 |
+
ms, mt = (s * ww).sum() / wsum, (t * ww).sum() / wsum
|
| 200 |
+
cov = ((s - ms) * (t - mt) * ww).sum() / wsum
|
| 201 |
+
var = ((s - ms) ** 2 * ww).sum() / wsum
|
| 202 |
+
if var < 1e-6:
|
| 203 |
+
continue
|
| 204 |
+
gain = cov / var
|
| 205 |
+
if not (0.2 <= gain <= 5.0):
|
| 206 |
+
continue
|
| 207 |
+
offset = mt - gain * ms
|
| 208 |
+
out[:, :, ch] = img2[:, :, ch].astype(np.float64) * gain + offset
|
| 209 |
+
return np.clip(out, 0, 255).astype(np.uint8)
|
| 210 |
+
except Exception as exc:
|
| 211 |
+
_log.warning("IR-MAD regression normalization failed (%s)", exc)
|
| 212 |
+
return None
|
app/cd_models/kpca_features.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KPCAMNet-style unsupervised deep change features (Kernel-PCA convolution).
|
| 2 |
+
|
| 3 |
+
Implements the core idea of "Unsupervised Change Detection in Multitemporal
|
| 4 |
+
VHR Images Based on Deep Kernel PCA Convolutional Mapping Network"
|
| 5 |
+
(Wu, Chen et al., IEEE TCYB 2022, https://github.com/ChenHongruixuan/KPCAMNet):
|
| 6 |
+
|
| 7 |
+
1. Patch vectors from both timestamps are projected through *shared* kernel-PCA
|
| 8 |
+
mappings (siamese: identical projection for both images), stacked in layers
|
| 9 |
+
like a small convolutional network. No labels or pretraining required β
|
| 10 |
+
projections are fitted on the image pair itself.
|
| 11 |
+
2. The per-pixel feature difference is mapped to a polar domain:
|
| 12 |
+
magnitude ``rho`` (change strength) and direction ``theta`` (change type).
|
| 13 |
+
3. ``rho`` is thresholded (Otsu + hysteresis by the caller) for the binary
|
| 14 |
+
change mask; ``theta`` supports discriminating change types downstream.
|
| 15 |
+
|
| 16 |
+
For CPU practicality the exact KPCA (O(N^2) kernel matrix) is replaced by the
|
| 17 |
+
standard Nystroem approximation + linear PCA, which scales linearly in pixels
|
| 18 |
+
while preserving the kernelized mapping. Layer projections are fitted on a
|
| 19 |
+
random pixel sample from BOTH images so the mapping is shared (siamese).
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
from dataclasses import dataclass
|
| 25 |
+
from typing import Optional, Tuple
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
_log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class KPCAChangeResult:
|
| 35 |
+
rho: np.ndarray # float32 [0,1], change magnitude, input resolution
|
| 36 |
+
theta: np.ndarray # float32 [-pi,pi], change direction, input resolution
|
| 37 |
+
n_layers: int
|
| 38 |
+
n_components: int
|
| 39 |
+
analysis_shape: Tuple[int, int]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _patch_stack(img: np.ndarray, patch: int) -> np.ndarray:
|
| 43 |
+
"""(H,W,C) -> (H,W,C*patch*patch) neighborhood features via shifted views."""
|
| 44 |
+
h, w, c = img.shape
|
| 45 |
+
r = patch // 2
|
| 46 |
+
padded = cv2.copyMakeBorder(img, r, r, r, r, cv2.BORDER_REFLECT)
|
| 47 |
+
views = []
|
| 48 |
+
for dy in range(patch):
|
| 49 |
+
for dx in range(patch):
|
| 50 |
+
views.append(padded[dy:dy + h, dx:dx + w])
|
| 51 |
+
return np.concatenate(views, axis=2).astype(np.float32)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _fit_shared_projection(feat_a: np.ndarray, feat_b: np.ndarray,
|
| 55 |
+
n_components: int, sample: int, seed: int):
|
| 56 |
+
"""Fit Nystroem(RBF) + PCA on a pixel sample drawn from both images."""
|
| 57 |
+
from sklearn.decomposition import PCA
|
| 58 |
+
from sklearn.kernel_approximation import Nystroem
|
| 59 |
+
from sklearn.pipeline import Pipeline
|
| 60 |
+
from sklearn.preprocessing import StandardScaler
|
| 61 |
+
|
| 62 |
+
rng = np.random.default_rng(seed)
|
| 63 |
+
flat = np.concatenate([
|
| 64 |
+
feat_a.reshape(-1, feat_a.shape[2]),
|
| 65 |
+
feat_b.reshape(-1, feat_b.shape[2]),
|
| 66 |
+
], axis=0)
|
| 67 |
+
take = min(sample, flat.shape[0])
|
| 68 |
+
idx = rng.choice(flat.shape[0], size=take, replace=False)
|
| 69 |
+
fit_data = flat[idx]
|
| 70 |
+
|
| 71 |
+
n_landmarks = int(min(128, max(32, take // 24)))
|
| 72 |
+
pipe = Pipeline([
|
| 73 |
+
("scale", StandardScaler()),
|
| 74 |
+
("nystroem", Nystroem(kernel="rbf", n_components=n_landmarks, random_state=seed)),
|
| 75 |
+
("pca", PCA(n_components=n_components, random_state=seed)),
|
| 76 |
+
])
|
| 77 |
+
pipe.fit(fit_data)
|
| 78 |
+
return pipe
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _transform_image(pipe, feat: np.ndarray) -> np.ndarray:
|
| 82 |
+
h, w, c = feat.shape
|
| 83 |
+
out = pipe.transform(feat.reshape(-1, c))
|
| 84 |
+
return out.reshape(h, w, -1).astype(np.float32)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def compute_kpca_change(
|
| 88 |
+
img1: np.ndarray,
|
| 89 |
+
img2: np.ndarray,
|
| 90 |
+
*,
|
| 91 |
+
n_components: int = 8,
|
| 92 |
+
n_layers: int = 2,
|
| 93 |
+
patch: int = 5,
|
| 94 |
+
max_side: int = 768,
|
| 95 |
+
fit_sample: int = 4000,
|
| 96 |
+
seed: int = 42,
|
| 97 |
+
) -> Optional[KPCAChangeResult]:
|
| 98 |
+
"""Siamese KPCA change features for an RGB image pair.
|
| 99 |
+
|
| 100 |
+
Returns rho/theta maps at the input resolution, or None on failure
|
| 101 |
+
(caller falls back to its previous behavior).
|
| 102 |
+
"""
|
| 103 |
+
try:
|
| 104 |
+
if img1.shape != img2.shape or img1.ndim != 3:
|
| 105 |
+
return None
|
| 106 |
+
full_h, full_w = img1.shape[:2]
|
| 107 |
+
|
| 108 |
+
# Bounded analysis resolution keeps the Nystroem transform CPU-friendly
|
| 109 |
+
scale = min(1.0, max_side / max(full_h, full_w))
|
| 110 |
+
if scale < 1.0:
|
| 111 |
+
ah, aw = max(64, int(full_h * scale)), max(64, int(full_w * scale))
|
| 112 |
+
a = cv2.resize(img1, (aw, ah), interpolation=cv2.INTER_AREA)
|
| 113 |
+
b = cv2.resize(img2, (aw, ah), interpolation=cv2.INTER_AREA)
|
| 114 |
+
else:
|
| 115 |
+
a, b = img1, img2
|
| 116 |
+
|
| 117 |
+
a = a.astype(np.float32) / 255.0
|
| 118 |
+
b = b.astype(np.float32) / 255.0
|
| 119 |
+
|
| 120 |
+
feat_a, feat_b = a, b
|
| 121 |
+
layer_patch = patch
|
| 122 |
+
for layer in range(max(1, n_layers)):
|
| 123 |
+
stack_a = _patch_stack(feat_a, layer_patch)
|
| 124 |
+
stack_b = _patch_stack(feat_b, layer_patch)
|
| 125 |
+
pipe = _fit_shared_projection(
|
| 126 |
+
stack_a, stack_b, n_components=n_components,
|
| 127 |
+
sample=fit_sample, seed=seed + layer,
|
| 128 |
+
)
|
| 129 |
+
feat_a = _transform_image(pipe, stack_a)
|
| 130 |
+
feat_b = _transform_image(pipe, stack_b)
|
| 131 |
+
layer_patch = 3 # deeper layers use tighter neighborhoods
|
| 132 |
+
|
| 133 |
+
diff = feat_a - feat_b
|
| 134 |
+
|
| 135 |
+
# Polar mapping (KPCAMNet): magnitude over all components; direction
|
| 136 |
+
# from the two most-informative difference components.
|
| 137 |
+
rho = np.sqrt(np.sum(diff * diff, axis=2))
|
| 138 |
+
var_order = np.argsort(np.var(diff.reshape(-1, diff.shape[2]), axis=0))[::-1]
|
| 139 |
+
d1 = diff[:, :, var_order[0]]
|
| 140 |
+
d2 = diff[:, :, var_order[1]] if diff.shape[2] > 1 else np.zeros_like(d1)
|
| 141 |
+
theta = np.arctan2(d2, d1)
|
| 142 |
+
|
| 143 |
+
# Robust [0,1] normalization of magnitude
|
| 144 |
+
hi = float(np.quantile(rho, 0.995))
|
| 145 |
+
if hi <= 1e-8:
|
| 146 |
+
hi = float(rho.max() + 1e-8)
|
| 147 |
+
rho = np.clip(rho / hi, 0.0, 1.0).astype(np.float32)
|
| 148 |
+
|
| 149 |
+
if rho.shape != (full_h, full_w):
|
| 150 |
+
rho = cv2.resize(rho, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
|
| 151 |
+
theta = cv2.resize(theta.astype(np.float32), (full_w, full_h),
|
| 152 |
+
interpolation=cv2.INTER_NEAREST)
|
| 153 |
+
|
| 154 |
+
return KPCAChangeResult(
|
| 155 |
+
rho=rho.astype(np.float32),
|
| 156 |
+
theta=theta.astype(np.float32),
|
| 157 |
+
n_layers=max(1, n_layers),
|
| 158 |
+
n_components=n_components,
|
| 159 |
+
analysis_shape=(a.shape[0], a.shape[1]),
|
| 160 |
+
)
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
_log.warning("KPCA change features failed (%s) β caller will fall back", exc)
|
| 163 |
+
return None
|
app/cd_models/kpca_method.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
KPCA-based unsupervised change detection (KPCAMNet-inspired).
|
| 3 |
+
|
| 4 |
+
Fits Kernel PCA filters on randomly sampled patches from both images (weight-
|
| 5 |
+
shared/siamese, no labels needed), projects the whole image through those
|
| 6 |
+
filters, then detects change via the L2 norm of the feature difference. This
|
| 7 |
+
is a single-stage reconstruction of the described KPCAMNet algorithm (not a
|
| 8 |
+
port of the original repo's code, which isn't part of this project) β scoped
|
| 9 |
+
down for CPU feasibility: bounded processing resolution and a modest training
|
| 10 |
+
patch count, since KernelPCA.transform cost scales with
|
| 11 |
+
n_query_patches x n_train_patches.
|
| 12 |
+
"""
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
from numpy.lib.stride_tricks import sliding_window_view
|
| 16 |
+
from sklearn.decomposition import KernelPCA
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _extract_all_patches(img: np.ndarray, patch_size: int) -> np.ndarray:
|
| 20 |
+
"""Every overlapping patch_size x patch_size patch, one per pixel (reflect-padded)."""
|
| 21 |
+
half = patch_size // 2
|
| 22 |
+
padded = np.pad(img, half, mode="reflect")
|
| 23 |
+
windows = sliding_window_view(padded, (patch_size, patch_size))
|
| 24 |
+
h, w = img.shape
|
| 25 |
+
return windows.reshape(h, w, -1)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _project_in_chunks(kpca: KernelPCA, flat_patches: np.ndarray, row_pixels: int,
|
| 29 |
+
n_components: int) -> np.ndarray:
|
| 30 |
+
"""KernelPCA.transform in row-chunks so memory stays bounded on large images."""
|
| 31 |
+
chunks = []
|
| 32 |
+
step = max(1, 200_000 // max(1, row_pixels)) * row_pixels
|
| 33 |
+
for i in range(0, flat_patches.shape[0], step):
|
| 34 |
+
chunks.append(kpca.transform(flat_patches[i:i + step]))
|
| 35 |
+
return np.concatenate(chunks, axis=0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def kpca_change_mask(img1: np.ndarray, img2: np.ndarray, patch_size: int = 5,
|
| 39 |
+
n_components: int = 8, gamma: float = 5e-4,
|
| 40 |
+
n_train_patches: int = 300, max_side: int = 768):
|
| 41 |
+
"""
|
| 42 |
+
Returns (magnitude_map float32 [0,1] at processed resolution, debug dict).
|
| 43 |
+
Caller handles thresholding/cleanup/resize-back so this stays testable in
|
| 44 |
+
isolation without pulling in the rest of the detection pipeline.
|
| 45 |
+
"""
|
| 46 |
+
if img1.shape != img2.shape:
|
| 47 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 48 |
+
|
| 49 |
+
h0, w0 = img1.shape[:2]
|
| 50 |
+
scale = min(1.0, max_side / max(h0, w0, 1))
|
| 51 |
+
if scale < 1.0:
|
| 52 |
+
nh, nw = max(1, int(h0 * scale)), max(1, int(w0 * scale))
|
| 53 |
+
img1_s = cv2.resize(img1, (nw, nh), interpolation=cv2.INTER_AREA)
|
| 54 |
+
img2_s = cv2.resize(img2, (nw, nh), interpolation=cv2.INTER_AREA)
|
| 55 |
+
else:
|
| 56 |
+
img1_s, img2_s = img1, img2
|
| 57 |
+
|
| 58 |
+
gray1 = cv2.cvtColor(img1_s, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 59 |
+
gray2 = cv2.cvtColor(img2_s, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 60 |
+
# Per-image z-score standardization (matches the described preprocessing)
|
| 61 |
+
gray1 = (gray1 - gray1.mean()) / (gray1.std() + 1e-8)
|
| 62 |
+
gray2 = (gray2 - gray2.mean()) / (gray2.std() + 1e-8)
|
| 63 |
+
|
| 64 |
+
h, w = gray1.shape
|
| 65 |
+
patches1 = _extract_all_patches(gray1, patch_size)
|
| 66 |
+
patches2 = _extract_all_patches(gray2, patch_size)
|
| 67 |
+
flat1 = patches1.reshape(-1, patches1.shape[-1])
|
| 68 |
+
flat2 = patches2.reshape(-1, patches2.shape[-1])
|
| 69 |
+
|
| 70 |
+
rng = np.random.default_rng(42)
|
| 71 |
+
half_n = max(1, n_train_patches // 2)
|
| 72 |
+
idx1 = rng.choice(flat1.shape[0], size=min(half_n, flat1.shape[0]), replace=False)
|
| 73 |
+
idx2 = rng.choice(flat2.shape[0], size=min(half_n, flat2.shape[0]), replace=False)
|
| 74 |
+
train_patches = np.concatenate([flat1[idx1], flat2[idx2]], axis=0)
|
| 75 |
+
|
| 76 |
+
n_components_eff = max(1, min(n_components, train_patches.shape[0] - 1))
|
| 77 |
+
kpca = KernelPCA(n_components=n_components_eff, kernel="rbf", gamma=gamma)
|
| 78 |
+
kpca.fit(train_patches)
|
| 79 |
+
|
| 80 |
+
feat1 = _project_in_chunks(kpca, flat1, w, n_components_eff).reshape(h, w, n_components_eff)
|
| 81 |
+
feat2 = _project_in_chunks(kpca, flat2, w, n_components_eff).reshape(h, w, n_components_eff)
|
| 82 |
+
|
| 83 |
+
diff = feat1 - feat2
|
| 84 |
+
magnitude = np.linalg.norm(diff, axis=-1)
|
| 85 |
+
mag_norm = magnitude / (magnitude.max() + 1e-8)
|
| 86 |
+
|
| 87 |
+
if scale < 1.0:
|
| 88 |
+
mag_norm = cv2.resize(mag_norm.astype(np.float32), (w0, h0), interpolation=cv2.INTER_LINEAR)
|
| 89 |
+
|
| 90 |
+
debug = {
|
| 91 |
+
"n_components": int(n_components_eff),
|
| 92 |
+
"n_train_patches": int(train_patches.shape[0]),
|
| 93 |
+
"processedSide": int(max(h, w)),
|
| 94 |
+
"patchSize": int(patch_size),
|
| 95 |
+
"gamma": float(gamma),
|
| 96 |
+
}
|
| 97 |
+
return mag_norm.astype(np.float32), debug
|
app/cd_models/model_utils.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared utilities for model loading, tile processing, and multi-scale detection.
|
| 3 |
+
"""
|
| 4 |
+
import cv2
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _tile_signature(tile: np.ndarray, size: int = 16) -> np.ndarray:
|
| 9 |
+
"""Cheap, deterministic per-tile fingerprint for a fast unchanged check.
|
| 10 |
+
|
| 11 |
+
Downsamples to a tiny ``size x size`` patch in LAB space (kept as a grid,
|
| 12 |
+
not flattened). Comparing two signatures is orders of magnitude cheaper
|
| 13 |
+
than a model forward pass, so it's usable as a pre-filter ahead of the
|
| 14 |
+
real scorer.
|
| 15 |
+
"""
|
| 16 |
+
small = cv2.resize(tile, (size, size), interpolation=cv2.INTER_AREA)
|
| 17 |
+
if small.ndim == 3:
|
| 18 |
+
small = cv2.cvtColor(small, cv2.COLOR_RGB2LAB)
|
| 19 |
+
return small.astype(np.float32)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _tiles_likely_unchanged(t1: np.ndarray, t2: np.ndarray, threshold: float) -> bool:
|
| 23 |
+
"""True when two same-size tiles are near-identical everywhere.
|
| 24 |
+
|
| 25 |
+
Uses the *worst-cell* (max) per-cell LAB distance on the downsampled grid,
|
| 26 |
+
not a mean or percentile β a small localized change concentrated in just
|
| 27 |
+
one or two cells out of the grid would be washed out by averaging (mean)
|
| 28 |
+
or ignored entirely by a percentile below ~100, which risks silently
|
| 29 |
+
skipping a real small change. Max cleanly separates that from sensor
|
| 30 |
+
noise (empirically ~1 unit of noise floor vs 25+ for even an 8x8px real
|
| 31 |
+
change in a 256px tile), so this is the safe choice for a pre-filter that
|
| 32 |
+
must not increase false negatives.
|
| 33 |
+
"""
|
| 34 |
+
sig1 = _tile_signature(t1)
|
| 35 |
+
sig2 = _tile_signature(t2)
|
| 36 |
+
diff = np.abs(sig1 - sig2)
|
| 37 |
+
if diff.ndim == 3:
|
| 38 |
+
diff = diff.max(axis=-1)
|
| 39 |
+
return float(diff.max()) <= threshold
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def make_blend_weights(tile_size: int, overlap: int) -> np.ndarray:
|
| 43 |
+
"""Raised-cosine (trapezoid) 2D weight for seamless tile stitching.
|
| 44 |
+
|
| 45 |
+
Ramps from 0 to 1 across the overlap band on each edge and stays flat in
|
| 46 |
+
the interior, so overlapping tile predictions blend without visible seams.
|
| 47 |
+
"""
|
| 48 |
+
overlap = max(0, min(overlap, tile_size // 2))
|
| 49 |
+
if overlap == 0:
|
| 50 |
+
return np.ones((tile_size, tile_size), dtype=np.float32)
|
| 51 |
+
ramp = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
|
| 52 |
+
flat = np.ones(tile_size - 2 * overlap, dtype=np.float32)
|
| 53 |
+
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
| 54 |
+
return np.outer(profile, profile).astype(np.float32)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def tiled_score_map(score_tile_fn, img1, img2, tile_size: int = 256,
|
| 58 |
+
overlap: int | None = None,
|
| 59 |
+
score_batch_fn=None, batch: int = 1,
|
| 60 |
+
skip_unchanged_threshold: float | None = None,
|
| 61 |
+
stats: dict | None = None):
|
| 62 |
+
"""Sliding-window tiled scoring with reflect padding + cosine blending.
|
| 63 |
+
|
| 64 |
+
``score_tile_fn(tile1, tile2)`` must return a float32 array in [0, 1] the
|
| 65 |
+
same height/width as the input tile. Returns a float32 change-probability
|
| 66 |
+
map cropped back to the original (h, w). This is the single shared
|
| 67 |
+
implementation used by every deep model so blending stays consistent.
|
| 68 |
+
|
| 69 |
+
When ``batch > 1`` and ``score_batch_fn`` is provided, tiles are scored in
|
| 70 |
+
groups (``score_batch_fn(list[(t1, t2)]) -> list[prob]``) so GPUs can run
|
| 71 |
+
several tiles per forward pass.
|
| 72 |
+
|
| 73 |
+
When ``skip_unchanged_threshold`` is set, tiles whose before/after content
|
| 74 |
+
is near-identical skip the expensive scorer entirely and are recorded as
|
| 75 |
+
zero change β a cheap pre-filter that speeds up runs where most of the
|
| 76 |
+
scene didn't change, without affecting tiles that actually did. Pass a
|
| 77 |
+
``stats`` dict to accumulate ``{"totalTiles", "skippedTiles"}`` into it.
|
| 78 |
+
"""
|
| 79 |
+
h, w = img1.shape[:2]
|
| 80 |
+
if overlap is None:
|
| 81 |
+
overlap = tile_size // 4
|
| 82 |
+
overlap = max(0, min(overlap, tile_size // 2))
|
| 83 |
+
stride = max(1, tile_size - overlap)
|
| 84 |
+
|
| 85 |
+
pad_h = (tile_size - h % tile_size) % tile_size
|
| 86 |
+
pad_w = (tile_size - w % tile_size) % tile_size
|
| 87 |
+
# Guarantee at least one full tile even for small inputs
|
| 88 |
+
pad_h = max(pad_h, max(0, tile_size - h))
|
| 89 |
+
pad_w = max(pad_w, max(0, tile_size - w))
|
| 90 |
+
if pad_h or pad_w:
|
| 91 |
+
img1 = np.pad(img1, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
|
| 92 |
+
img2 = np.pad(img2, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
|
| 93 |
+
|
| 94 |
+
ph, pw = img1.shape[:2]
|
| 95 |
+
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 96 |
+
count = np.zeros((ph, pw), dtype=np.float32)
|
| 97 |
+
weight_2d = make_blend_weights(tile_size, overlap)
|
| 98 |
+
|
| 99 |
+
ys = list(range(0, ph - tile_size + 1, stride))
|
| 100 |
+
xs = list(range(0, pw - tile_size + 1, stride))
|
| 101 |
+
# Ensure the far edges are always covered
|
| 102 |
+
if ys and ys[-1] != ph - tile_size:
|
| 103 |
+
ys.append(ph - tile_size)
|
| 104 |
+
if xs and xs[-1] != pw - tile_size:
|
| 105 |
+
xs.append(pw - tile_size)
|
| 106 |
+
|
| 107 |
+
coords = [(y0, x0) for y0 in ys for x0 in xs]
|
| 108 |
+
|
| 109 |
+
def _accumulate(y0, x0, prob):
|
| 110 |
+
if prob.shape != (tile_size, tile_size):
|
| 111 |
+
prob = cv2.resize(prob.astype(np.float32), (tile_size, tile_size),
|
| 112 |
+
interpolation=cv2.INTER_LINEAR)
|
| 113 |
+
score_sum[y0:y0 + tile_size, x0:x0 + tile_size] += prob * weight_2d
|
| 114 |
+
count[y0:y0 + tile_size, x0:x0 + tile_size] += weight_2d
|
| 115 |
+
|
| 116 |
+
zero_tile = np.zeros((tile_size, tile_size), dtype=np.float32)
|
| 117 |
+
skipped = 0
|
| 118 |
+
|
| 119 |
+
def _unchanged(t1, t2):
|
| 120 |
+
return (skip_unchanged_threshold is not None
|
| 121 |
+
and _tiles_likely_unchanged(t1, t2, skip_unchanged_threshold))
|
| 122 |
+
|
| 123 |
+
use_batch = score_batch_fn is not None and batch > 1
|
| 124 |
+
if use_batch:
|
| 125 |
+
for i in range(0, len(coords), batch):
|
| 126 |
+
chunk = coords[i:i + batch]
|
| 127 |
+
pairs = []
|
| 128 |
+
kept = []
|
| 129 |
+
for (y, x) in chunk:
|
| 130 |
+
t1 = np.ascontiguousarray(img1[y:y + tile_size, x:x + tile_size])
|
| 131 |
+
t2 = np.ascontiguousarray(img2[y:y + tile_size, x:x + tile_size])
|
| 132 |
+
if _unchanged(t1, t2):
|
| 133 |
+
skipped += 1
|
| 134 |
+
_accumulate(y, x, zero_tile)
|
| 135 |
+
continue
|
| 136 |
+
pairs.append((t1, t2))
|
| 137 |
+
kept.append((y, x))
|
| 138 |
+
if pairs:
|
| 139 |
+
probs = score_batch_fn(pairs)
|
| 140 |
+
for (y0, x0), prob in zip(kept, probs):
|
| 141 |
+
_accumulate(y0, x0, prob)
|
| 142 |
+
else:
|
| 143 |
+
for (y0, x0) in coords:
|
| 144 |
+
t1 = np.ascontiguousarray(img1[y0:y0 + tile_size, x0:x0 + tile_size])
|
| 145 |
+
t2 = np.ascontiguousarray(img2[y0:y0 + tile_size, x0:x0 + tile_size])
|
| 146 |
+
if _unchanged(t1, t2):
|
| 147 |
+
skipped += 1
|
| 148 |
+
_accumulate(y0, x0, zero_tile)
|
| 149 |
+
continue
|
| 150 |
+
_accumulate(y0, x0, score_tile_fn(t1, t2))
|
| 151 |
+
|
| 152 |
+
if stats is not None:
|
| 153 |
+
stats["totalTiles"] = stats.get("totalTiles", 0) + len(coords)
|
| 154 |
+
stats["skippedTiles"] = stats.get("skippedTiles", 0) + skipped
|
| 155 |
+
|
| 156 |
+
count = np.maximum(count, 1e-6)
|
| 157 |
+
avg = score_sum / count
|
| 158 |
+
return avg[:h, :w]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def split_into_tiles(img, tile_size=512, overlap=64):
|
| 162 |
+
"""
|
| 163 |
+
Split an image into overlapping tiles.
|
| 164 |
+
Returns list of (tile, y_offset, x_offset) tuples.
|
| 165 |
+
"""
|
| 166 |
+
h, w = img.shape[:2]
|
| 167 |
+
stride = tile_size - overlap
|
| 168 |
+
tiles = []
|
| 169 |
+
|
| 170 |
+
pad_h = (tile_size - h % tile_size) % tile_size if h % tile_size else 0
|
| 171 |
+
pad_w = (tile_size - w % tile_size) % tile_size if w % tile_size else 0
|
| 172 |
+
if pad_h or pad_w:
|
| 173 |
+
img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)) if img.ndim == 3
|
| 174 |
+
else ((0, pad_h), (0, pad_w)), mode="reflect")
|
| 175 |
+
|
| 176 |
+
ph, pw = img.shape[:2]
|
| 177 |
+
for y in range(0, ph - tile_size + 1, stride):
|
| 178 |
+
for x in range(0, pw - tile_size + 1, stride):
|
| 179 |
+
tile = img[y:y+tile_size, x:x+tile_size]
|
| 180 |
+
tiles.append((tile, y, x))
|
| 181 |
+
|
| 182 |
+
return tiles, (ph, pw), (h, w)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def merge_tile_masks(tile_results, padded_shape, orig_shape, tile_size=512, overlap=64):
|
| 186 |
+
"""
|
| 187 |
+
Merge tile-level binary masks back into a single full-resolution mask.
|
| 188 |
+
Uses raised-cosine blending to avoid tile boundary artifacts.
|
| 189 |
+
"""
|
| 190 |
+
ph, pw = padded_shape
|
| 191 |
+
h, w = orig_shape
|
| 192 |
+
|
| 193 |
+
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 194 |
+
count = np.zeros((ph, pw), dtype=np.float32)
|
| 195 |
+
|
| 196 |
+
ramp = np.linspace(0, 1, overlap)
|
| 197 |
+
flat = np.ones(tile_size - 2 * overlap)
|
| 198 |
+
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
| 199 |
+
weight_2d = np.outer(profile, profile).astype(np.float32)
|
| 200 |
+
|
| 201 |
+
for (mask_tile, y, x) in tile_results:
|
| 202 |
+
score = mask_tile.astype(np.float32) / 255.0 if mask_tile.max() > 1 else mask_tile.astype(np.float32)
|
| 203 |
+
if score.shape != (tile_size, tile_size):
|
| 204 |
+
score = cv2.resize(score, (tile_size, tile_size))
|
| 205 |
+
score_sum[y:y+tile_size, x:x+tile_size] += score * weight_2d
|
| 206 |
+
count[y:y+tile_size, x:x+tile_size] += weight_2d
|
| 207 |
+
|
| 208 |
+
count = np.maximum(count, 1e-6)
|
| 209 |
+
merged = score_sum / count
|
| 210 |
+
merged = merged[:h, :w]
|
| 211 |
+
return (merged * 255).astype(np.uint8)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def multiscale_detect(detect_fn, img1, img2, scales=(1.0, 0.5, 0.25)):
|
| 215 |
+
"""
|
| 216 |
+
Run a detection function at multiple scales and combine via logical OR.
|
| 217 |
+
detect_fn(img1, img2) -> uint8 mask [0|255].
|
| 218 |
+
Captures small structures at full res and large regions at coarse scales.
|
| 219 |
+
"""
|
| 220 |
+
h, w = img1.shape[:2]
|
| 221 |
+
combined = np.zeros((h, w), dtype=np.uint8)
|
| 222 |
+
|
| 223 |
+
for scale in scales:
|
| 224 |
+
if scale == 1.0:
|
| 225 |
+
s1, s2 = img1, img2
|
| 226 |
+
else:
|
| 227 |
+
sh, sw = max(64, int(h * scale)), max(64, int(w * scale))
|
| 228 |
+
s1 = cv2.resize(img1, (sw, sh), interpolation=cv2.INTER_AREA)
|
| 229 |
+
s2 = cv2.resize(img2, (sw, sh), interpolation=cv2.INTER_AREA)
|
| 230 |
+
|
| 231 |
+
mask = detect_fn(s1, s2)
|
| 232 |
+
|
| 233 |
+
if scale != 1.0:
|
| 234 |
+
mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
|
| 235 |
+
|
| 236 |
+
combined = np.maximum(combined, mask)
|
| 237 |
+
|
| 238 |
+
return combined
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def build_confidence_map(channels, weights=None):
|
| 242 |
+
"""
|
| 243 |
+
Build a [0-1] confidence map from multiple normalized signal channels.
|
| 244 |
+
Each channel should be a float32 array in [0,1].
|
| 245 |
+
If weights is None, uses equal weighting.
|
| 246 |
+
"""
|
| 247 |
+
if not channels:
|
| 248 |
+
return None
|
| 249 |
+
if weights is None:
|
| 250 |
+
weights = [1.0 / len(channels)] * len(channels)
|
| 251 |
+
total_w = sum(weights)
|
| 252 |
+
weights = [w / total_w for w in weights]
|
| 253 |
+
|
| 254 |
+
shape = channels[0].shape
|
| 255 |
+
conf = np.zeros(shape, dtype=np.float64)
|
| 256 |
+
for ch, w in zip(channels, weights):
|
| 257 |
+
if ch.shape != shape:
|
| 258 |
+
ch = cv2.resize(ch.astype(np.float32), (shape[1], shape[0]))
|
| 259 |
+
conf += w * ch.astype(np.float64)
|
| 260 |
+
|
| 261 |
+
return np.clip(conf, 0, 1).astype(np.float32)
|
app/cd_models/weights/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/database.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import create_engine, event
|
| 5 |
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 8 |
+
|
| 9 |
+
# Use /home/appuser/data on HF Spaces (writable), fall back to local data/ dir
|
| 10 |
+
_home_data = Path.home() / "data"
|
| 11 |
+
_local_data = BASE_DIR.parent / "data"
|
| 12 |
+
DATA_DIR = _home_data if os.environ.get("SPACE_ID") else _local_data
|
| 13 |
+
try:
|
| 14 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
except Exception:
|
| 16 |
+
pass # avoid crashing if read-only or permission issue (e.g. some HF environments)
|
| 17 |
+
|
| 18 |
+
DB_PATH = DATA_DIR / "satellite_app.db"
|
| 19 |
+
|
| 20 |
+
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DB_PATH}")
|
| 21 |
+
|
| 22 |
+
# Render gives postgres:// but SQLAlchemy 2.x requires postgresql://
|
| 23 |
+
if DATABASE_URL.startswith("postgres://"):
|
| 24 |
+
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
|
| 25 |
+
|
| 26 |
+
connect_args = {}
|
| 27 |
+
if DATABASE_URL.startswith("sqlite"):
|
| 28 |
+
# timeout = busy wait while another connection holds the write lock
|
| 29 |
+
# (progress updates vs the long-running detection worker).
|
| 30 |
+
connect_args = {"check_same_thread": False, "timeout": 60.0}
|
| 31 |
+
|
| 32 |
+
# pool_pre_ping: checks each connection before use and transparently reconnects
|
| 33 |
+
# if it's gone stale β matters for a remote DB (MySQL/Postgres) reached over
|
| 34 |
+
# the network, where idle connections can be dropped by the server or a
|
| 35 |
+
# firewall; irrelevant (and skipped) for local SQLite.
|
| 36 |
+
engine = create_engine(
|
| 37 |
+
DATABASE_URL,
|
| 38 |
+
connect_args=connect_args,
|
| 39 |
+
pool_pre_ping=not DATABASE_URL.startswith("sqlite"),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@event.listens_for(engine, "connect")
|
| 44 |
+
def _sqlite_pragmas(dbapi_conn, _connection_record):
|
| 45 |
+
if not DATABASE_URL.startswith("sqlite"):
|
| 46 |
+
return
|
| 47 |
+
cur = dbapi_conn.cursor()
|
| 48 |
+
try:
|
| 49 |
+
cur.execute("PRAGMA journal_mode=WAL")
|
| 50 |
+
cur.execute("PRAGMA busy_timeout=60000")
|
| 51 |
+
cur.execute("PRAGMA synchronous=NORMAL")
|
| 52 |
+
finally:
|
| 53 |
+
cur.close()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 57 |
+
Base = declarative_base()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_db():
|
| 61 |
+
db = SessionLocal()
|
| 62 |
+
try:
|
| 63 |
+
yield db
|
| 64 |
+
finally:
|
| 65 |
+
db.close()
|
app/dda/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Vedangsoft change detection module (dev Space / APP_MODE=dda)."""
|
app/dda/admin_routes.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vedangsoft admin diagnostics and maintenance (Phase 7)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import shutil
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from ..database import DATA_DIR, get_db
|
| 12 |
+
from ..models import DetectionRun, User
|
| 13 |
+
from .dda_auth import get_user_role
|
| 14 |
+
from .job_runner import is_job_runner_busy, reconcile_stale_jobs
|
| 15 |
+
from .tree.image_service import count_library_images
|
| 16 |
+
from .models import DetectionJob, RegionReview
|
| 17 |
+
from .rbac.permissions import require_module_permission
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _require_dda():
|
| 24 |
+
from .config import IS_DDA_MODE
|
| 25 |
+
if not IS_DDA_MODE:
|
| 26 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/admin/status")
|
| 30 |
+
def admin_status(
|
| 31 |
+
request: Request,
|
| 32 |
+
db: Session = Depends(get_db),
|
| 33 |
+
user: User = Depends(require_module_permission("admin", "view")),
|
| 34 |
+
):
|
| 35 |
+
"""System status for UAT / ops."""
|
| 36 |
+
_require_dda()
|
| 37 |
+
|
| 38 |
+
runs = db.query(DetectionRun).count()
|
| 39 |
+
jobs_queued = db.query(DetectionJob).filter(DetectionJob.status == "queued").count()
|
| 40 |
+
jobs_running = db.query(DetectionJob).filter(DetectionJob.status == "running").count()
|
| 41 |
+
fp_count = db.query(RegionReview).filter(RegionReview.status == "false_positive").count()
|
| 42 |
+
|
| 43 |
+
disk = shutil.disk_usage(DATA_DIR)
|
| 44 |
+
return {
|
| 45 |
+
"role": get_user_role(db, user),
|
| 46 |
+
"libraryImages": count_library_images(db),
|
| 47 |
+
"detectionRuns": runs,
|
| 48 |
+
"jobsQueued": jobs_queued,
|
| 49 |
+
"jobsRunning": jobs_running,
|
| 50 |
+
"runnerBusy": is_job_runner_busy(),
|
| 51 |
+
"falsePositiveArchive": fp_count,
|
| 52 |
+
"dataDir": str(DATA_DIR),
|
| 53 |
+
"diskFreeGb": round(disk.free / (1024 ** 3), 2),
|
| 54 |
+
"diskTotalGb": round(disk.total / (1024 ** 3), 2),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("/admin/reconcile-jobs")
|
| 59 |
+
def admin_reconcile_jobs(
|
| 60 |
+
request: Request,
|
| 61 |
+
db: Session = Depends(get_db),
|
| 62 |
+
user: User = Depends(require_module_permission("admin", "edit")),
|
| 63 |
+
):
|
| 64 |
+
_require_dda()
|
| 65 |
+
fixed = reconcile_stale_jobs(db)
|
| 66 |
+
return {"ok": True, "staleRunningFixed": fixed}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.delete("/admin/purge-runs")
|
| 70 |
+
def admin_purge_old_runs(
|
| 71 |
+
request: Request,
|
| 72 |
+
days: int = Query(90, ge=7, le=3650),
|
| 73 |
+
db: Session = Depends(get_db),
|
| 74 |
+
user: User = Depends(require_module_permission("admin", "delete")),
|
| 75 |
+
):
|
| 76 |
+
"""Delete detection runs older than N days (admin only)."""
|
| 77 |
+
_require_dda()
|
| 78 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
| 79 |
+
old = db.query(DetectionRun).filter(DetectionRun.created_at < cutoff).all()
|
| 80 |
+
count = 0
|
| 81 |
+
overlays_root = DATA_DIR
|
| 82 |
+
for run in old:
|
| 83 |
+
db.query(RegionReview).filter(RegionReview.run_id == run.id).delete()
|
| 84 |
+
for job in db.query(DetectionJob).filter(DetectionJob.run_id == run.id).all():
|
| 85 |
+
job.run_id = None
|
| 86 |
+
for path_attr in ("overlay_path", "before_full_path", "before_thumb_path", "after_thumb_path", "after_full_path"):
|
| 87 |
+
path_val = getattr(run, path_attr, None)
|
| 88 |
+
if path_val:
|
| 89 |
+
f = overlays_root / path_val
|
| 90 |
+
if f.exists():
|
| 91 |
+
f.unlink(missing_ok=True)
|
| 92 |
+
db.delete(run)
|
| 93 |
+
count += 1
|
| 94 |
+
db.commit()
|
| 95 |
+
return {"ok": True, "purgedRuns": count, "olderThanDays": days}
|
app/dda/applog.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory + file application log so the Logs page can view recent events."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import threading
|
| 6 |
+
from collections import deque
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
|
| 11 |
+
from ..database import DATA_DIR
|
| 12 |
+
|
| 13 |
+
_LOCK = threading.Lock()
|
| 14 |
+
_BUFFER: deque = deque(maxlen=4000)
|
| 15 |
+
_HANDLER: Optional["RingLogHandler"] = None
|
| 16 |
+
|
| 17 |
+
LOG_DIR = DATA_DIR / "logs"
|
| 18 |
+
LOG_FILE = LOG_DIR / "app.log"
|
| 19 |
+
|
| 20 |
+
_LEVELS = {
|
| 21 |
+
"DEBUG": logging.DEBUG,
|
| 22 |
+
"INFO": logging.INFO,
|
| 23 |
+
"WARNING": logging.WARNING,
|
| 24 |
+
"ERROR": logging.ERROR,
|
| 25 |
+
"CRITICAL": logging.CRITICAL,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RingLogHandler(logging.Handler):
|
| 30 |
+
def emit(self, record: logging.LogRecord) -> None:
|
| 31 |
+
try:
|
| 32 |
+
msg = self.format(record)
|
| 33 |
+
except Exception:
|
| 34 |
+
msg = record.getMessage()
|
| 35 |
+
created = datetime.fromtimestamp(record.created, tz=timezone.utc)
|
| 36 |
+
entry = {
|
| 37 |
+
"ts": created.isoformat(),
|
| 38 |
+
"level": record.levelname,
|
| 39 |
+
"logger": record.name,
|
| 40 |
+
"message": msg,
|
| 41 |
+
}
|
| 42 |
+
with _LOCK:
|
| 43 |
+
_BUFFER.append(entry)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def install_app_log_handler() -> None:
|
| 47 |
+
"""Attach once to the root logger (and uvicorn access) for the Logs UI."""
|
| 48 |
+
global _HANDLER
|
| 49 |
+
if _HANDLER is not None:
|
| 50 |
+
return
|
| 51 |
+
try:
|
| 52 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
except OSError:
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
handler = RingLogHandler(level=logging.INFO)
|
| 57 |
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
| 58 |
+
_HANDLER = handler
|
| 59 |
+
|
| 60 |
+
root = logging.getLogger()
|
| 61 |
+
root.addHandler(handler)
|
| 62 |
+
if root.level > logging.INFO or root.level == logging.NOTSET:
|
| 63 |
+
root.setLevel(logging.INFO)
|
| 64 |
+
|
| 65 |
+
file_handler = None
|
| 66 |
+
try:
|
| 67 |
+
file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
|
| 68 |
+
file_handler.setLevel(logging.INFO)
|
| 69 |
+
file_handler.setFormatter(logging.Formatter(
|
| 70 |
+
"%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
| 71 |
+
))
|
| 72 |
+
root.addHandler(file_handler)
|
| 73 |
+
except OSError:
|
| 74 |
+
file_handler = None
|
| 75 |
+
|
| 76 |
+
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
| 77 |
+
logging.getLogger(name).addHandler(handler)
|
| 78 |
+
if file_handler:
|
| 79 |
+
logging.getLogger(name).addHandler(file_handler)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def query_logs(
|
| 83 |
+
*,
|
| 84 |
+
level: str = "",
|
| 85 |
+
q: str = "",
|
| 86 |
+
logger_name: str = "",
|
| 87 |
+
limit: int = 200,
|
| 88 |
+
offset: int = 0,
|
| 89 |
+
) -> dict:
|
| 90 |
+
min_level = _LEVELS.get((level or "").upper(), 0)
|
| 91 |
+
needle = (q or "").strip().lower()
|
| 92 |
+
logger_f = (logger_name or "").strip().lower()
|
| 93 |
+
with _LOCK:
|
| 94 |
+
rows = list(_BUFFER)
|
| 95 |
+
rows.reverse()
|
| 96 |
+
filtered: List[dict] = []
|
| 97 |
+
for row in rows:
|
| 98 |
+
if min_level and _LEVELS.get(row["level"], 0) < min_level:
|
| 99 |
+
continue
|
| 100 |
+
if logger_f and logger_f not in (row.get("logger") or "").lower():
|
| 101 |
+
continue
|
| 102 |
+
if needle and needle not in (row.get("message") or "").lower() and needle not in (row.get("logger") or "").lower():
|
| 103 |
+
continue
|
| 104 |
+
filtered.append(row)
|
| 105 |
+
total = len(filtered)
|
| 106 |
+
page = filtered[offset:offset + limit]
|
| 107 |
+
return {
|
| 108 |
+
"total": total,
|
| 109 |
+
"offset": offset,
|
| 110 |
+
"limit": limit,
|
| 111 |
+
"logs": page,
|
| 112 |
+
"file": str(LOG_FILE) if LOG_FILE.exists() else "",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def clear_logs() -> dict:
|
| 117 |
+
with _LOCK:
|
| 118 |
+
_BUFFER.clear()
|
| 119 |
+
truncated = False
|
| 120 |
+
try:
|
| 121 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
LOG_FILE.write_text("", encoding="utf-8")
|
| 123 |
+
truncated = True
|
| 124 |
+
except OSError:
|
| 125 |
+
pass
|
| 126 |
+
return {"ok": True, "cleared": True, "fileTruncated": truncated}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def log_file_path() -> Path:
|
| 130 |
+
return LOG_FILE
|
app/dda/audit_routes.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API for the Audit Log page β user activity trail (login/logout, uploads,
|
| 2 |
+
detections, reports, admin/master-data changes)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from ..database import get_db
|
| 12 |
+
from ..models import User
|
| 13 |
+
from .rbac.permissions import require_module_permission
|
| 14 |
+
from .tree.audit_service import isoformat_ist
|
| 15 |
+
from .tree.models import AuditLog
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _require_dda():
|
| 21 |
+
from .config import IS_DDA_MODE
|
| 22 |
+
if not IS_DDA_MODE:
|
| 23 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _entry_dict(entry: AuditLog) -> dict:
|
| 27 |
+
return {
|
| 28 |
+
"id": entry.id,
|
| 29 |
+
"timestamp": isoformat_ist(entry.action_date),
|
| 30 |
+
"user": entry.action_by or "",
|
| 31 |
+
"role": entry.role or "",
|
| 32 |
+
"action": entry.action,
|
| 33 |
+
"module": entry.module or "",
|
| 34 |
+
"nodeId": entry.node_id,
|
| 35 |
+
"oldValue": entry.old_value or "",
|
| 36 |
+
"newValue": entry.new_value or "",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _parse_date(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
|
| 41 |
+
if not value:
|
| 42 |
+
return None
|
| 43 |
+
try:
|
| 44 |
+
dt = datetime.fromisoformat(value)
|
| 45 |
+
except ValueError:
|
| 46 |
+
raise HTTPException(status_code=400, detail=f"Invalid date: {value!r} (expected YYYY-MM-DD)")
|
| 47 |
+
if end_of_day:
|
| 48 |
+
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
| 49 |
+
return dt
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/audit-log")
|
| 53 |
+
def list_audit_log(
|
| 54 |
+
action: str = Query(""),
|
| 55 |
+
module: str = Query(""),
|
| 56 |
+
role: str = Query(""),
|
| 57 |
+
from_date: Optional[str] = Query(None, alias="from"),
|
| 58 |
+
to_date: Optional[str] = Query(None, alias="to"),
|
| 59 |
+
limit: int = Query(100, ge=1, le=1000),
|
| 60 |
+
offset: int = Query(0, ge=0),
|
| 61 |
+
user: User = Depends(require_module_permission("audit_log", "view")),
|
| 62 |
+
db: Session = Depends(get_db),
|
| 63 |
+
):
|
| 64 |
+
_require_dda()
|
| 65 |
+
q = db.query(AuditLog)
|
| 66 |
+
if action:
|
| 67 |
+
q = q.filter(AuditLog.action == action)
|
| 68 |
+
if module:
|
| 69 |
+
q = q.filter(AuditLog.module == module)
|
| 70 |
+
if role:
|
| 71 |
+
q = q.filter(AuditLog.role == role)
|
| 72 |
+
start = _parse_date(from_date)
|
| 73 |
+
if start:
|
| 74 |
+
q = q.filter(AuditLog.action_date >= start)
|
| 75 |
+
end = _parse_date(to_date, end_of_day=True)
|
| 76 |
+
if end:
|
| 77 |
+
q = q.filter(AuditLog.action_date <= end)
|
| 78 |
+
|
| 79 |
+
total = q.count()
|
| 80 |
+
rows = q.order_by(AuditLog.action_date.desc()).offset(offset).limit(limit).all()
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"logs": [_entry_dict(r) for r in rows],
|
| 84 |
+
"total": total,
|
| 85 |
+
"limit": limit,
|
| 86 |
+
"offset": offset,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/audit-log/filters")
|
| 91 |
+
def audit_log_filters(
|
| 92 |
+
user: User = Depends(require_module_permission("audit_log", "view")),
|
| 93 |
+
db: Session = Depends(get_db),
|
| 94 |
+
):
|
| 95 |
+
"""Distinct action/module/role values, for populating the filter dropdowns."""
|
| 96 |
+
_require_dda()
|
| 97 |
+
actions = [r[0] for r in db.query(AuditLog.action).distinct().order_by(AuditLog.action) if r[0]]
|
| 98 |
+
modules = [r[0] for r in db.query(AuditLog.module).distinct().order_by(AuditLog.module) if r[0]]
|
| 99 |
+
roles = [r[0] for r in db.query(AuditLog.role).distinct().order_by(AuditLog.role) if r[0]]
|
| 100 |
+
return {"actions": actions, "modules": modules, "roles": roles}
|
app/dda/auto_detect.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Periodic automatic detection for identified same-area pairs.
|
| 2 |
+
|
| 3 |
+
Manual Change Detection is unchanged (user clicks Run). Automatic mode also
|
| 4 |
+
keeps Run-now; this module queues the same identified Before/After pairs on a
|
| 5 |
+
cadence (default 10 days) and saves reports without anyone opening the UI.
|
| 6 |
+
|
| 7 |
+
The FastAPI process must stay running for the schedule to fire.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import threading
|
| 16 |
+
from datetime import datetime, timedelta, timezone
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Optional
|
| 19 |
+
|
| 20 |
+
from sqlalchemy.orm import Session
|
| 21 |
+
|
| 22 |
+
from ..database import SessionLocal
|
| 23 |
+
from ..models import User
|
| 24 |
+
from .models import AutoDetectSettings, AutoPairSchedule, DetectionJob
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
AUTO_SOURCE = "auto_schedule"
|
| 29 |
+
|
| 30 |
+
_scheduler_lock = threading.Lock()
|
| 31 |
+
_tick_lock = threading.Lock()
|
| 32 |
+
_scheduler_started = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _utcnow() -> datetime:
|
| 36 |
+
return datetime.now(timezone.utc)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _aware(dt: Optional[datetime]) -> Optional[datetime]:
|
| 40 |
+
if dt is None:
|
| 41 |
+
return None
|
| 42 |
+
if dt.tzinfo is None:
|
| 43 |
+
return dt.replace(tzinfo=timezone.utc)
|
| 44 |
+
return dt
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
_IST = timezone(timedelta(hours=5, minutes=30))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def parse_run_at(value: str) -> str:
|
| 51 |
+
raw = (value or "02:00").strip()
|
| 52 |
+
try:
|
| 53 |
+
parts = raw.split(":")
|
| 54 |
+
hour = int(parts[0])
|
| 55 |
+
minute = int(parts[1]) if len(parts) > 1 else 0
|
| 56 |
+
except (TypeError, ValueError):
|
| 57 |
+
hour, minute = 2, 0
|
| 58 |
+
hour = max(0, min(23, hour))
|
| 59 |
+
minute = max(0, min(59, minute))
|
| 60 |
+
return f"{hour:02d}:{minute:02d}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def clamp_interval_days(value: Any, default: int = 10) -> int:
|
| 64 |
+
try:
|
| 65 |
+
days = int(value)
|
| 66 |
+
except (TypeError, ValueError):
|
| 67 |
+
days = default
|
| 68 |
+
return max(1, min(365, days))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_or_create_settings(db: Session) -> AutoDetectSettings:
|
| 72 |
+
row = db.query(AutoDetectSettings).filter(AutoDetectSettings.id == 1).first()
|
| 73 |
+
if row:
|
| 74 |
+
return row
|
| 75 |
+
row = AutoDetectSettings(
|
| 76 |
+
id=1,
|
| 77 |
+
running=False,
|
| 78 |
+
interval_days=clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10")),
|
| 79 |
+
run_at=parse_run_at(os.environ.get("AUTO_DETECT_RUN_AT", "02:00")),
|
| 80 |
+
next_run_at=None,
|
| 81 |
+
)
|
| 82 |
+
db.add(row)
|
| 83 |
+
db.commit()
|
| 84 |
+
db.refresh(row)
|
| 85 |
+
return row
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def scheduler_is_armed() -> bool:
|
| 89 |
+
from .config import IS_DDA_MODE
|
| 90 |
+
if not IS_DDA_MODE:
|
| 91 |
+
return False
|
| 92 |
+
raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
|
| 93 |
+
if raw in ("0", "false", "no", "off"):
|
| 94 |
+
return False
|
| 95 |
+
db = SessionLocal()
|
| 96 |
+
try:
|
| 97 |
+
return bool(get_or_create_settings(db).running)
|
| 98 |
+
except Exception:
|
| 99 |
+
return False
|
| 100 |
+
finally:
|
| 101 |
+
db.close()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def auto_detect_enabled() -> bool:
|
| 105 |
+
"""Feature exists in DDA mode unless explicitly disabled in env.
|
| 106 |
+
|
| 107 |
+
The background *queue* only runs when the operator clicks Start
|
| 108 |
+
(``scheduler_is_armed``). This flag only means the Automatic tab controls
|
| 109 |
+
are available.
|
| 110 |
+
"""
|
| 111 |
+
from .config import IS_DDA_MODE
|
| 112 |
+
if not IS_DDA_MODE:
|
| 113 |
+
return False
|
| 114 |
+
raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
|
| 115 |
+
return raw not in ("0", "false", "no", "off")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_auto_detect_interval_days() -> int:
|
| 119 |
+
db = SessionLocal()
|
| 120 |
+
try:
|
| 121 |
+
return clamp_interval_days(get_or_create_settings(db).interval_days)
|
| 122 |
+
except Exception:
|
| 123 |
+
return clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10"))
|
| 124 |
+
finally:
|
| 125 |
+
db.close()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def next_run_at_from_clock(run_at: str, now: Optional[datetime] = None) -> datetime:
|
| 129 |
+
"""Next future IST clock time for HH:MM. Never returns 'now' (no launch burst)."""
|
| 130 |
+
ist_now = (now or datetime.now(_IST)).astimezone(_IST)
|
| 131 |
+
hhmm = parse_run_at(run_at)
|
| 132 |
+
hour, minute = (int(p) for p in hhmm.split(":"))
|
| 133 |
+
candidate = ist_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 134 |
+
if candidate <= ist_now + timedelta(seconds=45):
|
| 135 |
+
candidate += timedelta(days=1)
|
| 136 |
+
return candidate.astimezone(timezone.utc)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def cancel_queued_auto_jobs(db: Session, reason: str) -> int:
|
| 140 |
+
cancelled = 0
|
| 141 |
+
pending = (
|
| 142 |
+
db.query(DetectionJob)
|
| 143 |
+
.filter(DetectionJob.status == "queued")
|
| 144 |
+
.all()
|
| 145 |
+
)
|
| 146 |
+
for job in pending:
|
| 147 |
+
if not is_auto_schedule_job(job):
|
| 148 |
+
continue
|
| 149 |
+
job.status = "failed"
|
| 150 |
+
job.error_message = reason[:2000]
|
| 151 |
+
job.completed_at = _utcnow()
|
| 152 |
+
cancelled += 1
|
| 153 |
+
if cancelled:
|
| 154 |
+
db.commit()
|
| 155 |
+
logger.info("Cancelled %d queued auto-detect job(s)", cancelled)
|
| 156 |
+
return cancelled
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def pair_key_for(before_path: str, after_path: str) -> str:
|
| 160 |
+
a = (before_path or "").replace("\\", "/").strip().lower()
|
| 161 |
+
b = (after_path or "").replace("\\", "/").strip().lower()
|
| 162 |
+
return hashlib.sha256(f"{a}|{b}".encode("utf-8")).hexdigest()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def job_source(job: DetectionJob) -> str:
|
| 166 |
+
try:
|
| 167 |
+
return str(json.loads(job.params_json or "{}").get("source") or "")
|
| 168 |
+
except json.JSONDecodeError:
|
| 169 |
+
return ""
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def is_auto_schedule_job(job: Optional[DetectionJob]) -> bool:
|
| 173 |
+
return bool(job) and job_source(job) == AUTO_SOURCE
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def is_auto_schedule_run(db: Session, run_id: int) -> bool:
|
| 177 |
+
if not run_id:
|
| 178 |
+
return False
|
| 179 |
+
job = (
|
| 180 |
+
db.query(DetectionJob)
|
| 181 |
+
.filter(DetectionJob.run_id == run_id)
|
| 182 |
+
.order_by(DetectionJob.id.desc())
|
| 183 |
+
.first()
|
| 184 |
+
)
|
| 185 |
+
return is_auto_schedule_job(job)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def user_can_access_run(db: Session, run_id: int, user_id: int) -> bool:
|
| 189 |
+
from ..models import DetectionRun
|
| 190 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == run_id).first()
|
| 191 |
+
if not run:
|
| 192 |
+
return False
|
| 193 |
+
if run.user_id == user_id:
|
| 194 |
+
return True
|
| 195 |
+
return is_auto_schedule_run(db, run_id)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _norm_path(path: str) -> str:
|
| 199 |
+
return (path or "").replace("\\", "/").strip()
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _paths_match(job: DetectionJob, before: str, after: str) -> bool:
|
| 203 |
+
try:
|
| 204 |
+
params = json.loads(job.params_json or "{}")
|
| 205 |
+
except json.JSONDecodeError:
|
| 206 |
+
return False
|
| 207 |
+
return (
|
| 208 |
+
_norm_path(params.get("base_path", "")) == _norm_path(before)
|
| 209 |
+
and _norm_path(params.get("comparison_path", "")) == _norm_path(after)
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _schedule_owner_id(db: Session) -> int:
|
| 214 |
+
admin = (
|
| 215 |
+
db.query(User)
|
| 216 |
+
.filter(User.role == "admin")
|
| 217 |
+
.order_by(User.id.asc())
|
| 218 |
+
.first()
|
| 219 |
+
)
|
| 220 |
+
if admin:
|
| 221 |
+
return admin.id
|
| 222 |
+
user = (
|
| 223 |
+
db.query(User)
|
| 224 |
+
.filter(User.email.notlike("__guest__%"))
|
| 225 |
+
.order_by(User.id.asc())
|
| 226 |
+
.first()
|
| 227 |
+
)
|
| 228 |
+
if user:
|
| 229 |
+
return user.id
|
| 230 |
+
from ..auth import get_or_create_guest_user
|
| 231 |
+
return get_or_create_guest_user(db).id
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _load_identified_pairs(db: Session) -> list[dict[str, Any]]:
|
| 235 |
+
from .tree.area_groups import load_cached_area_groups
|
| 236 |
+
|
| 237 |
+
payload = load_cached_area_groups(db)
|
| 238 |
+
pairs = []
|
| 239 |
+
for group in payload.get("groups") or []:
|
| 240 |
+
before = group.get("beforePath") or (group.get("suggestedBefore") or {}).get("path")
|
| 241 |
+
after = group.get("afterPath") or (group.get("suggestedAfter") or {}).get("path")
|
| 242 |
+
if not before or not after or _norm_path(before) == _norm_path(after):
|
| 243 |
+
continue
|
| 244 |
+
pairs.append({
|
| 245 |
+
"group_id": group.get("id") or "",
|
| 246 |
+
"label": group.get("label") or Path(before).name,
|
| 247 |
+
"before_path": _norm_path(before),
|
| 248 |
+
"after_path": _norm_path(after),
|
| 249 |
+
"before_date": group.get("beforeDate"),
|
| 250 |
+
"after_date": group.get("afterDate"),
|
| 251 |
+
})
|
| 252 |
+
return pairs
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
|
| 256 |
+
interval = get_auto_detect_interval_days()
|
| 257 |
+
live_keys = set()
|
| 258 |
+
now = _utcnow()
|
| 259 |
+
for pair in pairs:
|
| 260 |
+
key = pair_key_for(pair["before_path"], pair["after_path"])
|
| 261 |
+
live_keys.add(key)
|
| 262 |
+
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
|
| 263 |
+
if row:
|
| 264 |
+
row.group_label = pair["label"]
|
| 265 |
+
row.before_path = pair["before_path"]
|
| 266 |
+
row.after_path = pair["after_path"]
|
| 267 |
+
row.interval_days = interval
|
| 268 |
+
row.enabled = True
|
| 269 |
+
row.updated_at = now
|
| 270 |
+
else:
|
| 271 |
+
db.add(AutoPairSchedule(
|
| 272 |
+
pair_key=key,
|
| 273 |
+
group_label=pair["label"],
|
| 274 |
+
before_path=pair["before_path"],
|
| 275 |
+
after_path=pair["after_path"],
|
| 276 |
+
interval_days=interval,
|
| 277 |
+
enabled=True,
|
| 278 |
+
))
|
| 279 |
+
stale = (
|
| 280 |
+
db.query(AutoPairSchedule)
|
| 281 |
+
.filter(AutoPairSchedule.enabled.is_(True))
|
| 282 |
+
.all()
|
| 283 |
+
)
|
| 284 |
+
for row in stale:
|
| 285 |
+
if row.pair_key not in live_keys:
|
| 286 |
+
row.enabled = False
|
| 287 |
+
row.updated_at = now
|
| 288 |
+
db.commit()
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
|
| 292 |
+
pending = (
|
| 293 |
+
db.query(DetectionJob)
|
| 294 |
+
.filter(DetectionJob.status.in_(("queued", "running")))
|
| 295 |
+
.order_by(DetectionJob.id.desc())
|
| 296 |
+
.limit(80)
|
| 297 |
+
.all()
|
| 298 |
+
)
|
| 299 |
+
for job in pending:
|
| 300 |
+
if _paths_match(job, before, after):
|
| 301 |
+
return job
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _latest_success_for_paths(db: Session, before: str, after: str) -> Optional[datetime]:
|
| 306 |
+
done = (
|
| 307 |
+
db.query(DetectionJob)
|
| 308 |
+
.filter(DetectionJob.status == "completed", DetectionJob.completed_at.isnot(None))
|
| 309 |
+
.order_by(DetectionJob.completed_at.desc())
|
| 310 |
+
.limit(80)
|
| 311 |
+
.all()
|
| 312 |
+
)
|
| 313 |
+
for job in done:
|
| 314 |
+
if _paths_match(job, before, after):
|
| 315 |
+
return _aware(job.completed_at)
|
| 316 |
+
return None
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _is_due(row: AutoPairSchedule, last_success: Optional[datetime], now: datetime) -> bool:
|
| 320 |
+
interval = timedelta(days=max(1, int(row.interval_days or get_auto_detect_interval_days())))
|
| 321 |
+
last = _aware(row.last_completed_at) or last_success
|
| 322 |
+
if last and now < last + interval:
|
| 323 |
+
return False
|
| 324 |
+
last_enq = _aware(row.last_enqueued_at)
|
| 325 |
+
if row.last_error and last_enq and now < last_enq + timedelta(hours=12):
|
| 326 |
+
return False
|
| 327 |
+
if last_enq and last and now < last_enq + timedelta(minutes=30):
|
| 328 |
+
return False
|
| 329 |
+
return True
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _enqueue_pair(db: Session, row: AutoPairSchedule) -> Optional[DetectionJob]:
|
| 333 |
+
from .job_runner import create_local_folder_job
|
| 334 |
+
from .local_routes import safe_resolve
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
safe_resolve(row.before_path)
|
| 338 |
+
safe_resolve(row.after_path)
|
| 339 |
+
except Exception as exc:
|
| 340 |
+
row.last_error = f"Library path missing: {exc}"[:2000]
|
| 341 |
+
db.commit()
|
| 342 |
+
logger.warning("Auto-detect skip %s: %s", row.group_label, exc)
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
title = f"[Auto] {row.group_label}: {Path(row.before_path).name} vs {Path(row.after_path).name}"
|
| 346 |
+
job = create_local_folder_job(
|
| 347 |
+
db,
|
| 348 |
+
base_path=row.before_path,
|
| 349 |
+
comparison_path=row.after_path,
|
| 350 |
+
title=title,
|
| 351 |
+
created_by=_schedule_owner_id(db),
|
| 352 |
+
extra_params={
|
| 353 |
+
"source": AUTO_SOURCE,
|
| 354 |
+
"pair_key": row.pair_key,
|
| 355 |
+
"group_label": row.group_label,
|
| 356 |
+
},
|
| 357 |
+
)
|
| 358 |
+
row.last_enqueued_at = _utcnow()
|
| 359 |
+
row.last_job_id = job.id
|
| 360 |
+
row.last_error = ""
|
| 361 |
+
db.commit()
|
| 362 |
+
logger.info(
|
| 363 |
+
"Auto-detect queued job %d for %s (%s β %s)",
|
| 364 |
+
job.id, row.group_label, Path(row.before_path).name, Path(row.after_path).name,
|
| 365 |
+
)
|
| 366 |
+
return job
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def tick_auto_detect() -> int:
|
| 370 |
+
"""Sync identified pairs and enqueue those that are due. Returns jobs queued."""
|
| 371 |
+
if not scheduler_is_armed():
|
| 372 |
+
return 0
|
| 373 |
+
if not _tick_lock.acquire(blocking=False):
|
| 374 |
+
return 0
|
| 375 |
+
queued = 0
|
| 376 |
+
db = SessionLocal()
|
| 377 |
+
try:
|
| 378 |
+
pairs = _load_identified_pairs(db)
|
| 379 |
+
_sync_schedules(db, pairs)
|
| 380 |
+
now = _utcnow()
|
| 381 |
+
rows = (
|
| 382 |
+
db.query(AutoPairSchedule)
|
| 383 |
+
.filter(AutoPairSchedule.enabled.is_(True))
|
| 384 |
+
.order_by(AutoPairSchedule.id.asc())
|
| 385 |
+
.all()
|
| 386 |
+
)
|
| 387 |
+
for row in rows:
|
| 388 |
+
if _pending_job_for_paths(db, row.before_path, row.after_path):
|
| 389 |
+
continue
|
| 390 |
+
last_success = _latest_success_for_paths(db, row.before_path, row.after_path)
|
| 391 |
+
if last_success and (
|
| 392 |
+
row.last_completed_at is None or _aware(row.last_completed_at) < last_success
|
| 393 |
+
):
|
| 394 |
+
row.last_completed_at = last_success
|
| 395 |
+
db.commit()
|
| 396 |
+
if not _is_due(row, last_success, now):
|
| 397 |
+
continue
|
| 398 |
+
if _enqueue_pair(db, row):
|
| 399 |
+
queued += 1
|
| 400 |
+
if queued:
|
| 401 |
+
logger.info("Auto-detect tick queued %d pair(s)", queued)
|
| 402 |
+
except Exception:
|
| 403 |
+
logger.exception("Auto-detect tick failed")
|
| 404 |
+
db.rollback()
|
| 405 |
+
queued = 0
|
| 406 |
+
finally:
|
| 407 |
+
db.close()
|
| 408 |
+
_tick_lock.release()
|
| 409 |
+
if queued:
|
| 410 |
+
from .job_runner import start_next_queued_job
|
| 411 |
+
start_next_queued_job()
|
| 412 |
+
return queued
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def mark_schedule_from_job(job_id: int, *, status: str, run_id: Optional[int], error: str = "") -> None:
|
| 416 |
+
db = SessionLocal()
|
| 417 |
+
try:
|
| 418 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 419 |
+
if not is_auto_schedule_job(job):
|
| 420 |
+
# Manual success for the same paths still postpones the next auto run.
|
| 421 |
+
if status == "completed" and job:
|
| 422 |
+
try:
|
| 423 |
+
params = json.loads(job.params_json or "{}")
|
| 424 |
+
except json.JSONDecodeError:
|
| 425 |
+
return
|
| 426 |
+
key = pair_key_for(params.get("base_path", ""), params.get("comparison_path", ""))
|
| 427 |
+
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
|
| 428 |
+
if row:
|
| 429 |
+
row.last_completed_at = _utcnow()
|
| 430 |
+
row.last_job_id = job_id
|
| 431 |
+
if run_id:
|
| 432 |
+
row.last_run_id = run_id
|
| 433 |
+
row.last_error = ""
|
| 434 |
+
db.commit()
|
| 435 |
+
return
|
| 436 |
+
try:
|
| 437 |
+
params = json.loads(job.params_json or "{}")
|
| 438 |
+
except json.JSONDecodeError:
|
| 439 |
+
params = {}
|
| 440 |
+
key = params.get("pair_key") or pair_key_for(
|
| 441 |
+
params.get("base_path", ""), params.get("comparison_path", ""))
|
| 442 |
+
row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
|
| 443 |
+
if not row:
|
| 444 |
+
return
|
| 445 |
+
row.last_job_id = job_id
|
| 446 |
+
if status == "completed":
|
| 447 |
+
row.last_completed_at = _utcnow()
|
| 448 |
+
row.last_run_id = run_id
|
| 449 |
+
row.last_error = ""
|
| 450 |
+
else:
|
| 451 |
+
row.last_error = (error or "")[:2000]
|
| 452 |
+
db.commit()
|
| 453 |
+
except Exception:
|
| 454 |
+
logger.exception("Could not update auto-detect schedule for job %s", job_id)
|
| 455 |
+
db.rollback()
|
| 456 |
+
finally:
|
| 457 |
+
db.close()
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def refresh_schedule_rows() -> None:
|
| 461 |
+
"""Create/update schedule rows from identified pairs without enqueueing jobs."""
|
| 462 |
+
db = SessionLocal()
|
| 463 |
+
try:
|
| 464 |
+
pairs = _load_identified_pairs(db)
|
| 465 |
+
_sync_schedules(db, pairs)
|
| 466 |
+
except Exception:
|
| 467 |
+
logger.exception("Auto-detect schedule sync failed")
|
| 468 |
+
db.rollback()
|
| 469 |
+
finally:
|
| 470 |
+
db.close()
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def schedule_status_payload(db: Session) -> dict:
|
| 474 |
+
settings = get_or_create_settings(db)
|
| 475 |
+
interval = clamp_interval_days(settings.interval_days)
|
| 476 |
+
feature_on = auto_detect_enabled()
|
| 477 |
+
armed = bool(settings.running) and feature_on
|
| 478 |
+
next_run = _aware(settings.next_run_at)
|
| 479 |
+
rows = db.query(AutoPairSchedule).order_by(AutoPairSchedule.group_label.asc()).all()
|
| 480 |
+
out = []
|
| 481 |
+
for row in rows:
|
| 482 |
+
last = _aware(row.last_completed_at)
|
| 483 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == row.last_job_id).first() if row.last_job_id else None
|
| 484 |
+
out.append({
|
| 485 |
+
"pairKey": row.pair_key,
|
| 486 |
+
"label": row.group_label,
|
| 487 |
+
"beforePath": row.before_path,
|
| 488 |
+
"afterPath": row.after_path,
|
| 489 |
+
"enabled": bool(row.enabled) and armed,
|
| 490 |
+
"intervalDays": interval,
|
| 491 |
+
"lastCompletedAt": last.isoformat() if last else None,
|
| 492 |
+
"nextDueAt": next_run.isoformat() if next_run and armed else None,
|
| 493 |
+
"dueNow": False,
|
| 494 |
+
"lastJobId": row.last_job_id,
|
| 495 |
+
"lastRunId": row.last_run_id,
|
| 496 |
+
"lastJobStatus": job.status if job else None,
|
| 497 |
+
"lastError": row.last_error or "",
|
| 498 |
+
})
|
| 499 |
+
return {
|
| 500 |
+
"enabled": feature_on,
|
| 501 |
+
"running": armed,
|
| 502 |
+
"intervalDays": interval,
|
| 503 |
+
"runAt": parse_run_at(settings.run_at),
|
| 504 |
+
"nextRunAt": next_run.isoformat() if next_run and armed else None,
|
| 505 |
+
"pairs": out,
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def start_schedule(*, interval_days: int, run_at: str) -> dict:
|
| 510 |
+
if not auto_detect_enabled():
|
| 511 |
+
raise ValueError("Automatic detection is disabled on the server.")
|
| 512 |
+
db = SessionLocal()
|
| 513 |
+
try:
|
| 514 |
+
refresh_schedule_rows()
|
| 515 |
+
settings = get_or_create_settings(db)
|
| 516 |
+
settings.interval_days = clamp_interval_days(interval_days)
|
| 517 |
+
settings.run_at = parse_run_at(run_at)
|
| 518 |
+
settings.running = True
|
| 519 |
+
settings.next_run_at = next_run_at_from_clock(settings.run_at)
|
| 520 |
+
settings.updated_at = _utcnow()
|
| 521 |
+
db.commit()
|
| 522 |
+
payload = schedule_status_payload(db)
|
| 523 |
+
logger.info(
|
| 524 |
+
"Auto-detect started: interval=%d day(s) at %s IST; first queue %s (not now)",
|
| 525 |
+
settings.interval_days, settings.run_at, settings.next_run_at,
|
| 526 |
+
)
|
| 527 |
+
return payload
|
| 528 |
+
finally:
|
| 529 |
+
db.close()
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def stop_schedule() -> dict:
|
| 533 |
+
db = SessionLocal()
|
| 534 |
+
try:
|
| 535 |
+
settings = get_or_create_settings(db)
|
| 536 |
+
settings.running = False
|
| 537 |
+
settings.next_run_at = None
|
| 538 |
+
settings.updated_at = _utcnow()
|
| 539 |
+
db.commit()
|
| 540 |
+
cancel_queued_auto_jobs(
|
| 541 |
+
db,
|
| 542 |
+
"Cancelled because automatic schedule was stopped.",
|
| 543 |
+
)
|
| 544 |
+
logger.info("Auto-detect stopped; queued auto jobs cancelled")
|
| 545 |
+
return schedule_status_payload(db)
|
| 546 |
+
finally:
|
| 547 |
+
db.close()
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _maybe_fire_scheduled_tick() -> None:
|
| 551 |
+
if not scheduler_is_armed():
|
| 552 |
+
return
|
| 553 |
+
db = SessionLocal()
|
| 554 |
+
try:
|
| 555 |
+
settings = get_or_create_settings(db)
|
| 556 |
+
due_at = _aware(settings.next_run_at)
|
| 557 |
+
if not due_at or _utcnow() < due_at:
|
| 558 |
+
return
|
| 559 |
+
slot = due_at
|
| 560 |
+
settings.next_run_at = slot + timedelta(days=clamp_interval_days(settings.interval_days))
|
| 561 |
+
db.commit()
|
| 562 |
+
except Exception:
|
| 563 |
+
logger.exception("Auto-detect schedule clock failed")
|
| 564 |
+
db.rollback()
|
| 565 |
+
return
|
| 566 |
+
finally:
|
| 567 |
+
db.close()
|
| 568 |
+
tick_auto_detect()
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def _scheduler_loop() -> None:
|
| 572 |
+
while True:
|
| 573 |
+
threading.Event().wait(20)
|
| 574 |
+
try:
|
| 575 |
+
_maybe_fire_scheduled_tick()
|
| 576 |
+
except Exception:
|
| 577 |
+
logger.exception("Auto-detect waiter failed")
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def reset_schedule_on_process_start() -> None:
|
| 581 |
+
"""Disarm the queue and cancel leftover auto jobs. Safe to call twice."""
|
| 582 |
+
db = SessionLocal()
|
| 583 |
+
try:
|
| 584 |
+
settings = get_or_create_settings(db)
|
| 585 |
+
settings.running = False
|
| 586 |
+
settings.next_run_at = None
|
| 587 |
+
db.commit()
|
| 588 |
+
cancel_queued_auto_jobs(
|
| 589 |
+
db,
|
| 590 |
+
"Cancelled on server start. Automatic detection does not run until you click Start.",
|
| 591 |
+
)
|
| 592 |
+
except Exception:
|
| 593 |
+
logger.exception("Could not reset auto-detect on launch")
|
| 594 |
+
db.rollback()
|
| 595 |
+
finally:
|
| 596 |
+
db.close()
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
def start_auto_detect_scheduler() -> None:
|
| 600 |
+
"""Start the idle waiter. Never enqueues jobs on launch."""
|
| 601 |
+
global _scheduler_started
|
| 602 |
+
reset_schedule_on_process_start()
|
| 603 |
+
|
| 604 |
+
if not auto_detect_enabled():
|
| 605 |
+
logger.info("Automatic pair detection feature disabled")
|
| 606 |
+
return
|
| 607 |
+
with _scheduler_lock:
|
| 608 |
+
if _scheduler_started:
|
| 609 |
+
return
|
| 610 |
+
_scheduler_started = True
|
| 611 |
+
logger.info(
|
| 612 |
+
"Automatic detection is idle. Set interval/time on the Automatic tab and click Start."
|
| 613 |
+
)
|
| 614 |
+
threading.Thread(target=_scheduler_loop, daemon=True, name="dda-auto-detect").start()
|
app/dda/bootstrap.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from sqlalchemy import text as sa_text
|
| 5 |
+
|
| 6 |
+
from ..database import engine
|
| 7 |
+
from .config import IS_DDA_MODE, ensure_library_dirs, get_storage_root, is_hf_hosted
|
| 8 |
+
from .admin_routes import router as admin_router
|
| 9 |
+
from .dashboard_routes import router as dashboard_router
|
| 10 |
+
from .jobs_routes import router as jobs_router
|
| 11 |
+
from .library_routes import router as library_router
|
| 12 |
+
from .local_routes import router as local_router
|
| 13 |
+
from .reports_routes import router as reports_router
|
| 14 |
+
from .review_routes import router as review_router
|
| 15 |
+
from .training_routes import router as training_router
|
| 16 |
+
from .tree.routes import router as tree_router
|
| 17 |
+
from .rbac.routes import router as rbac_router
|
| 18 |
+
from .masters_routes import router as masters_router
|
| 19 |
+
from .logs_routes import router as logs_router
|
| 20 |
+
from .audit_routes import router as audit_router
|
| 21 |
+
from .exception_routes import router as exception_router
|
| 22 |
+
from .dda_auth import seed_dda_admin
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _migrate_detection_jobs_nullable() -> None:
|
| 28 |
+
"""SQLite legacy schema required image asset FKs; tree library jobs use paths only.
|
| 29 |
+
|
| 30 |
+
SQLite-only: rebuilds the table via SQLite's PRAGMA/copy-and-rename
|
| 31 |
+
pattern because SQLite can't ALTER COLUMN to drop NOT NULL directly.
|
| 32 |
+
Irrelevant on MySQL/PostgreSQL β a fresh create_all() there already
|
| 33 |
+
creates the column with the current (nullable) definition, and neither
|
| 34 |
+
speaks SQLite's PRAGMA dialect, so skip cleanly instead of erroring.
|
| 35 |
+
"""
|
| 36 |
+
if engine.dialect.name != "sqlite":
|
| 37 |
+
return
|
| 38 |
+
try:
|
| 39 |
+
with engine.connect() as conn:
|
| 40 |
+
rows = conn.execute(sa_text("PRAGMA table_info(dda_detection_jobs)")).fetchall()
|
| 41 |
+
if not rows:
|
| 42 |
+
return
|
| 43 |
+
by_name = {r[1]: r for r in rows}
|
| 44 |
+
base_col = by_name.get("base_image_id")
|
| 45 |
+
if not base_col or base_col[3] == 0:
|
| 46 |
+
return
|
| 47 |
+
logger.info("Migrating dda_detection_jobs to allow NULL image IDs for path-based jobs")
|
| 48 |
+
conn.execute(sa_text("PRAGMA foreign_keys=OFF"))
|
| 49 |
+
conn.execute(sa_text("""
|
| 50 |
+
CREATE TABLE dda_detection_jobs_new (
|
| 51 |
+
id INTEGER NOT NULL PRIMARY KEY,
|
| 52 |
+
status VARCHAR(32),
|
| 53 |
+
base_image_id INTEGER,
|
| 54 |
+
comparison_image_id INTEGER,
|
| 55 |
+
method VARCHAR(64),
|
| 56 |
+
params_json TEXT,
|
| 57 |
+
run_id INTEGER,
|
| 58 |
+
error_message TEXT,
|
| 59 |
+
notify_email VARCHAR(255),
|
| 60 |
+
created_by INTEGER,
|
| 61 |
+
started_at DATETIME,
|
| 62 |
+
completed_at DATETIME,
|
| 63 |
+
created_at DATETIME,
|
| 64 |
+
FOREIGN KEY(base_image_id) REFERENCES dda_image_assets (id),
|
| 65 |
+
FOREIGN KEY(comparison_image_id) REFERENCES dda_image_assets (id),
|
| 66 |
+
FOREIGN KEY(run_id) REFERENCES detection_runs (id),
|
| 67 |
+
FOREIGN KEY(created_by) REFERENCES users (id)
|
| 68 |
+
)
|
| 69 |
+
"""))
|
| 70 |
+
conn.execute(sa_text("""
|
| 71 |
+
INSERT INTO dda_detection_jobs_new (
|
| 72 |
+
id, status, base_image_id, comparison_image_id, method, params_json,
|
| 73 |
+
run_id, error_message, notify_email, created_by,
|
| 74 |
+
started_at, completed_at, created_at
|
| 75 |
+
)
|
| 76 |
+
SELECT
|
| 77 |
+
id, status, base_image_id, comparison_image_id, method, params_json,
|
| 78 |
+
run_id, error_message, notify_email, created_by,
|
| 79 |
+
started_at, completed_at, created_at
|
| 80 |
+
FROM dda_detection_jobs
|
| 81 |
+
"""))
|
| 82 |
+
conn.execute(sa_text("DROP TABLE dda_detection_jobs"))
|
| 83 |
+
conn.execute(sa_text("ALTER TABLE dda_detection_jobs_new RENAME TO dda_detection_jobs"))
|
| 84 |
+
conn.execute(sa_text("PRAGMA foreign_keys=ON"))
|
| 85 |
+
conn.commit()
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
logger.warning("Detection jobs nullable migration skipped: %s", exc)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def init_dda_database():
|
| 91 |
+
"""Run DDA-specific startup tasks (dirs, seed, migrations)."""
|
| 92 |
+
if not IS_DDA_MODE:
|
| 93 |
+
return
|
| 94 |
+
ensure_library_dirs()
|
| 95 |
+
get_storage_root().mkdir(parents=True, exist_ok=True)
|
| 96 |
+
logger.info("DDA database dialect=%s", engine.dialect.name)
|
| 97 |
+
try:
|
| 98 |
+
with engine.connect() as conn:
|
| 99 |
+
for stmt in (
|
| 100 |
+
"ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'",
|
| 101 |
+
"ALTER TABLE users ADD COLUMN role_id INTEGER DEFAULT NULL",
|
| 102 |
+
"ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) DEFAULT NULL",
|
| 103 |
+
"ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0",
|
| 104 |
+
"ALTER TABLE users ADD COLUMN totp_confirmed_at DATETIME DEFAULT NULL",
|
| 105 |
+
"ALTER TABLE users ADD COLUMN phone VARCHAR(20) DEFAULT NULL",
|
| 106 |
+
"ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
|
| 107 |
+
"ALTER TABLE dda_roles ADD COLUMN is_active BOOLEAN DEFAULT 1",
|
| 108 |
+
"ALTER TABLE dda_audit_logs ADD COLUMN role VARCHAR(64) DEFAULT ''",
|
| 109 |
+
"ALTER TABLE dda_audit_logs ADD COLUMN module VARCHAR(64) DEFAULT ''",
|
| 110 |
+
):
|
| 111 |
+
try:
|
| 112 |
+
conn.execute(sa_text(stmt))
|
| 113 |
+
conn.commit()
|
| 114 |
+
except Exception:
|
| 115 |
+
conn.rollback()
|
| 116 |
+
try:
|
| 117 |
+
conn.execute(sa_text(
|
| 118 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_phone ON users (phone)"
|
| 119 |
+
))
|
| 120 |
+
conn.commit()
|
| 121 |
+
except Exception:
|
| 122 |
+
conn.rollback()
|
| 123 |
+
try:
|
| 124 |
+
conn.execute(sa_text(
|
| 125 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_dda_region_reviews_run_region "
|
| 126 |
+
"ON dda_region_reviews (run_id, region_id)"
|
| 127 |
+
))
|
| 128 |
+
conn.commit()
|
| 129 |
+
except Exception:
|
| 130 |
+
conn.rollback()
|
| 131 |
+
_migrate_detection_jobs_nullable()
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
logger.warning("DDA schema migration skipped: %s", exc)
|
| 134 |
+
|
| 135 |
+
from ..database import Base, SessionLocal
|
| 136 |
+
from .tree.models import AuditLog, ExceptionLog, ImageLibrary, TreeNode # noqa: F401
|
| 137 |
+
try:
|
| 138 |
+
Base.metadata.create_all(bind=engine, tables=[
|
| 139 |
+
TreeNode.__table__,
|
| 140 |
+
ImageLibrary.__table__,
|
| 141 |
+
AuditLog.__table__,
|
| 142 |
+
ExceptionLog.__table__,
|
| 143 |
+
])
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
logger.warning("Tree table create skipped: %s", exc)
|
| 146 |
+
|
| 147 |
+
db = SessionLocal()
|
| 148 |
+
try:
|
| 149 |
+
seed_dda_admin(db)
|
| 150 |
+
from .rbac.seed import seed_rbac
|
| 151 |
+
try:
|
| 152 |
+
seed_rbac(db)
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
logger.warning("RBAC seed skipped: %s", exc)
|
| 155 |
+
try:
|
| 156 |
+
from .seed import seed_delhi_hierarchy
|
| 157 |
+
seed_delhi_hierarchy(db)
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
logger.warning("Zone/village seed skipped: %s", exc)
|
| 160 |
+
from .tree.migration import run_tree_migration
|
| 161 |
+
mig = run_tree_migration(db)
|
| 162 |
+
logger.info("Tree migration: %s", mig)
|
| 163 |
+
from .auto_detect import reset_schedule_on_process_start
|
| 164 |
+
reset_schedule_on_process_start()
|
| 165 |
+
from .job_runner import reconcile_stale_jobs
|
| 166 |
+
reconcile_stale_jobs(db)
|
| 167 |
+
finally:
|
| 168 |
+
db.close()
|
| 169 |
+
|
| 170 |
+
def _fs_sync_worker():
|
| 171 |
+
from ..database import SessionLocal as SL
|
| 172 |
+
from .tree.image_service import count_library_images
|
| 173 |
+
from .tree.sync_service import sync_from_filesystem
|
| 174 |
+
|
| 175 |
+
sdb = SL()
|
| 176 |
+
try:
|
| 177 |
+
try:
|
| 178 |
+
sync_stats = sync_from_filesystem(sdb, check_orphans=False)
|
| 179 |
+
logger.info("Filesystem sync at startup: %s", sync_stats)
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
logger.warning("Filesystem sync at startup failed: %s", exc)
|
| 182 |
+
try:
|
| 183 |
+
total = count_library_images(sdb)
|
| 184 |
+
logger.info(
|
| 185 |
+
"DDA tree library ready (hosted=%s): %d images, storage=%s",
|
| 186 |
+
is_hf_hosted(),
|
| 187 |
+
total,
|
| 188 |
+
get_storage_root(),
|
| 189 |
+
)
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
logger.warning("Tree library scan at startup failed: %s", exc)
|
| 192 |
+
finally:
|
| 193 |
+
sdb.close()
|
| 194 |
+
|
| 195 |
+
import threading
|
| 196 |
+
threading.Thread(target=_fs_sync_worker, daemon=True, name="dda-fs-sync").start()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def setup_dda(app: FastAPI) -> None:
|
| 200 |
+
if not IS_DDA_MODE:
|
| 201 |
+
logger.info("APP_MODE=legacy β DDA routes disabled")
|
| 202 |
+
return
|
| 203 |
+
app.include_router(tree_router, prefix="/api/dda", tags=["dda-tree"])
|
| 204 |
+
app.include_router(library_router, prefix="/api/dda", tags=["dda"])
|
| 205 |
+
app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
|
| 206 |
+
app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
|
| 207 |
+
app.include_router(review_router, prefix="/api/dda", tags=["dda-review"])
|
| 208 |
+
app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
|
| 209 |
+
app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
|
| 210 |
+
app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
|
| 211 |
+
app.include_router(rbac_router, prefix="/api/dda", tags=["dda-rbac"])
|
| 212 |
+
app.include_router(dashboard_router, prefix="/api/dda", tags=["dda-dashboard"])
|
| 213 |
+
app.include_router(masters_router, prefix="/api/dda", tags=["dda-masters"])
|
| 214 |
+
app.include_router(logs_router, prefix="/api/dda", tags=["dda-logs"])
|
| 215 |
+
app.include_router(audit_router, prefix="/api/dda", tags=["dda-audit"])
|
| 216 |
+
app.include_router(exception_router, prefix="/api/dda", tags=["dda-exceptions"])
|
| 217 |
+
logger.info("APP_MODE=dda β DDA routes enabled (tree, library, jobs, reports, review, training, admin, local, rbac, dashboard, masters, logs)")
|
app/dda/change_type_map.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map internal detection_engine labels β Vedangsoft report change types (FR-04)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
DDA_NEW_CONSTRUCTION = "New Construction"
|
| 7 |
+
DDA_DEMOLITION = "Demolition"
|
| 8 |
+
DDA_EXTENSION = "Extension"
|
| 9 |
+
DDA_VEGETATION = "Vegetation Change"
|
| 10 |
+
DDA_OTHER = "Other"
|
| 11 |
+
|
| 12 |
+
_KEYWORDS = [
|
| 13 |
+
(DDA_DEMOLITION, ("demolition", "clearing", "removed", "debris")),
|
| 14 |
+
(DDA_EXTENSION, ("expansion", "widening", "extension", "renovation", "addition")),
|
| 15 |
+
(DDA_VEGETATION, ("vegetation", "tree", "forest", "green", "crop")),
|
| 16 |
+
(DDA_NEW_CONSTRUCTION, (
|
| 17 |
+
"construction", "building", "structure", "road", "pavement",
|
| 18 |
+
"temporary", "roof", "concrete", "foundation", "solar", "panel",
|
| 19 |
+
"industrial",
|
| 20 |
+
)),
|
| 21 |
+
# Bare/soil/water only β do NOT match the substring "land" alone (too broad).
|
| 22 |
+
(DDA_OTHER, ("water body", "bare land", "bare earth", "soil change")),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def map_to_dda_change_type(internal_type: str) -> str:
|
| 27 |
+
"""Return canonical Vedangsoft change type for a detection_engine object_type string."""
|
| 28 |
+
if not internal_type:
|
| 29 |
+
return DDA_OTHER
|
| 30 |
+
lower = internal_type.lower()
|
| 31 |
+
for dda_type, keywords in _KEYWORDS:
|
| 32 |
+
if any(kw in lower for kw in keywords):
|
| 33 |
+
return dda_type
|
| 34 |
+
if "new" in lower:
|
| 35 |
+
return DDA_NEW_CONSTRUCTION
|
| 36 |
+
return DDA_OTHER
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def enrich_region_for_dda(region: dict, *, dda_change_type: Optional[str] = None) -> dict:
|
| 40 |
+
"""Add Vedangsoft report fields to a serialized region dict."""
|
| 41 |
+
internal = region.get("objectType") or region.get("object_type") or ""
|
| 42 |
+
out = dict(region)
|
| 43 |
+
out["ddaChangeType"] = dda_change_type or map_to_dda_change_type(internal)
|
| 44 |
+
out["internalObjectType"] = internal
|
| 45 |
+
out["reviewStatus"] = region.get("reviewStatus", "pending")
|
| 46 |
+
return out
|
app/dda/config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from ..database import DATA_DIR
|
| 6 |
+
|
| 7 |
+
APP_MODE_RAW = os.environ.get("APP_MODE", "").strip().lower()
|
| 8 |
+
_SPACE_ID = os.environ.get("SPACE_ID", "").strip().lower()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def is_hf_hosted() -> bool:
|
| 12 |
+
return bool(_SPACE_ID)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _is_dda_mode() -> bool:
|
| 16 |
+
if APP_MODE_RAW == "legacy":
|
| 17 |
+
return False
|
| 18 |
+
if APP_MODE_RAW == "dda":
|
| 19 |
+
return True
|
| 20 |
+
# APP_MODE unset: auto-enable on satdetect-dev only (production satdetect stays legacy)
|
| 21 |
+
return _SPACE_ID.endswith("/satdetect-dev")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_public_base_url() -> str:
|
| 25 |
+
"""Public URL for deep links (email, share). Override with PUBLIC_BASE_URL."""
|
| 26 |
+
explicit = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
|
| 27 |
+
if explicit:
|
| 28 |
+
return explicit
|
| 29 |
+
if _SPACE_ID:
|
| 30 |
+
return f"https://{_SPACE_ID.replace('/', '-')}.hf.space"
|
| 31 |
+
return "http://localhost:7860"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
IS_DDA_MODE = _is_dda_mode()
|
| 35 |
+
APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Project root: change_detection_webapp/
|
| 39 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_writable_library_root() -> Path:
|
| 43 |
+
"""Persistent folder for uploads (HF: /home/appuser/data/library_sources)."""
|
| 44 |
+
return (DATA_DIR / "library_sources").resolve()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_storage_root() -> Path:
|
| 48 |
+
"""Tree library root (doc: root_directory / STORAGE_ROOT)."""
|
| 49 |
+
explicit = os.environ.get("STORAGE_ROOT", "").strip()
|
| 50 |
+
if explicit:
|
| 51 |
+
return Path(explicit).resolve()
|
| 52 |
+
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 53 |
+
return Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
|
| 54 |
+
return get_writable_library_root()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_library_roots() -> List[Path]:
|
| 58 |
+
"""Folders scanned for library images (tree storage root)."""
|
| 59 |
+
roots: List[Path] = []
|
| 60 |
+
sr = get_storage_root()
|
| 61 |
+
if sr not in roots:
|
| 62 |
+
roots.append(sr)
|
| 63 |
+
if os.environ.get("LOCAL_LIBRARY_ROOT"):
|
| 64 |
+
p = Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve()
|
| 65 |
+
if p not in roots:
|
| 66 |
+
roots.append(p)
|
| 67 |
+
if is_hf_hosted():
|
| 68 |
+
wr = get_writable_library_root()
|
| 69 |
+
if wr not in roots:
|
| 70 |
+
roots.append(wr)
|
| 71 |
+
for candidate in (get_writable_library_root(), PROJECT_ROOT / "library_sources"):
|
| 72 |
+
resolved = candidate.resolve()
|
| 73 |
+
if resolved not in roots:
|
| 74 |
+
roots.append(resolved)
|
| 75 |
+
return roots
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Primary root (shown in UI) β first entry from get_library_roots()
|
| 79 |
+
LOCAL_LIBRARY_ROOT = get_library_roots()[0]
|
| 80 |
+
|
| 81 |
+
LIBRARY_DIR = DATA_DIR / "library"
|
| 82 |
+
THUMBS_DIR = LIBRARY_DIR / "thumbs"
|
| 83 |
+
PREVIEWS_DIR = LIBRARY_DIR / "previews"
|
| 84 |
+
LOCAL_THUMB_CACHE = DATA_DIR / "library_cache" / "thumbs"
|
| 85 |
+
|
| 86 |
+
# Library upload limit (default 15 GB; override with MAX_GEOTIFF_MB / MAX_IMAGE_MB)
|
| 87 |
+
_MAX_UPLOAD_MB_DEFAULT = "15360" # 15 * 1024
|
| 88 |
+
MAX_GEOTIFF_BYTES = int(os.environ.get("MAX_GEOTIFF_MB", _MAX_UPLOAD_MB_DEFAULT)) * 1024 * 1024
|
| 89 |
+
|
| 90 |
+
# PNG/JPEG use the same cap unless MAX_IMAGE_MB is set separately
|
| 91 |
+
MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_MB", _MAX_UPLOAD_MB_DEFAULT)) * 1024 * 1024
|
| 92 |
+
|
| 93 |
+
ALLOWED_EXTENSIONS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def ensure_library_dirs() -> None:
|
| 97 |
+
for root in get_library_roots():
|
| 98 |
+
try:
|
| 99 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
except OSError:
|
| 101 |
+
pass
|
| 102 |
+
for d in (LIBRARY_DIR, THUMBS_DIR, PREVIEWS_DIR, LOCAL_THUMB_CACHE):
|
| 103 |
+
try:
|
| 104 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
except OSError:
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def ensure_local_year_folders() -> None:
|
| 110 |
+
"""Create default year folders under each library root if missing."""
|
| 111 |
+
from datetime import datetime
|
| 112 |
+
current = datetime.now().year
|
| 113 |
+
for root in get_library_roots():
|
| 114 |
+
for year in (current - 1, current, current + 1):
|
| 115 |
+
try:
|
| 116 |
+
(root / str(year)).mkdir(parents=True, exist_ok=True)
|
| 117 |
+
except OSError:
|
| 118 |
+
pass
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def max_upload_bytes_for_extension(ext: str) -> int:
|
| 122 |
+
if ext in (".tif", ".tiff"):
|
| 123 |
+
return MAX_GEOTIFF_BYTES
|
| 124 |
+
return MAX_IMAGE_BYTES
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def geotiff_io_available() -> bool:
|
| 128 |
+
try:
|
| 129 |
+
import rasterio # noqa: F401
|
| 130 |
+
return True
|
| 131 |
+
except ImportError:
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def get_detection_max_side() -> int:
|
| 136 |
+
"""Max pixel dimension for GeoTIFF load + detection pipeline (higher = sharper, more RAM)."""
|
| 137 |
+
from ..detection_config import get_detection_max_side as _central
|
| 138 |
+
return _central()
|
app/dda/dashboard_routes.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard aggregation API.
|
| 2 |
+
|
| 3 |
+
Backs the app's home dashboard (Change Detection Overview, region/village
|
| 4 |
+
breakdowns, location comparisons, trend, recent reports). Scoped to the
|
| 5 |
+
requesting user the same way /api/history and reports.js are β own runs
|
| 6 |
+
plus any auto-scheduled runs everyone can see β so two different users see
|
| 7 |
+
different dashboards, exactly like they already see different Reports lists.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
from datetime import datetime, timedelta, timezone
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 16 |
+
from sqlalchemy import or_
|
| 17 |
+
from sqlalchemy.orm import Session
|
| 18 |
+
|
| 19 |
+
from ..database import get_db
|
| 20 |
+
from ..models import DetectionRun, User
|
| 21 |
+
from .auto_detect import is_auto_schedule_job
|
| 22 |
+
from .dda_auth import current_dda_user
|
| 23 |
+
from .models import DetectionJob
|
| 24 |
+
from .review_service import bulk_classification_counts
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
UNASSIGNED = "Unassigned"
|
| 29 |
+
DEFAULT_ROLE = "analyst"
|
| 30 |
+
|
| 31 |
+
# Matches DEFAULT_ROLES rank order in app/dda/rbac/seed.py β keeps the role
|
| 32 |
+
# breakdown in a stable, meaningful order instead of alphabetical/count order.
|
| 33 |
+
ROLE_ORDER = ["viewer", "uploader", "analyst", "admin"]
|
| 34 |
+
|
| 35 |
+
MONTH_NAMES = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 36 |
+
|
| 37 |
+
_IST = timezone(timedelta(hours=5, minutes=30))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _isoformat_ist(dt):
|
| 41 |
+
"""Same conversion as detect_service._isoformat_ist, duplicated locally
|
| 42 |
+
to avoid importing detect_service (which pulls in geo_regions) just for
|
| 43 |
+
date formatting."""
|
| 44 |
+
if dt is None:
|
| 45 |
+
return None
|
| 46 |
+
if dt.tzinfo is None:
|
| 47 |
+
dt = dt.replace(tzinfo=timezone.utc)
|
| 48 |
+
return dt.astimezone(_IST).isoformat()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _require_dda():
|
| 52 |
+
from .config import IS_DDA_MODE
|
| 53 |
+
if not IS_DDA_MODE:
|
| 54 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _run_thumbs(run: DetectionRun) -> dict:
|
| 58 |
+
return {
|
| 59 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 60 |
+
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if (run.before_thumb_path or "").strip() else None,
|
| 61 |
+
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if (run.after_thumb_path or "").strip() else None,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _run_summary(row: dict) -> dict:
|
| 66 |
+
run = row["run"]
|
| 67 |
+
return {
|
| 68 |
+
"id": run.id,
|
| 69 |
+
"title": run.title,
|
| 70 |
+
"zone": row["zone"],
|
| 71 |
+
"village": row["village"],
|
| 72 |
+
"role": row["role"],
|
| 73 |
+
"method": run.method,
|
| 74 |
+
"changePercentage": run.change_percentage,
|
| 75 |
+
"regionsCount": run.regions_count or 0,
|
| 76 |
+
"unclassified": row["unclassified"],
|
| 77 |
+
"confirmed": row["confirmed"],
|
| 78 |
+
"falsePositive": row["falsePositive"],
|
| 79 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 80 |
+
**_run_thumbs(run),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _accessible_run_filter(db: Session, user: User):
|
| 85 |
+
"""Same run-visibility rule as GET /api/history in app/main.py: the
|
| 86 |
+
user's own runs, plus any run that came from the auto-schedule (visible
|
| 87 |
+
to everyone, not just whoever happened to be logged in when it fired)."""
|
| 88 |
+
auto_ids = [
|
| 89 |
+
j.run_id for j in (
|
| 90 |
+
db.query(DetectionJob)
|
| 91 |
+
.filter(DetectionJob.run_id.isnot(None))
|
| 92 |
+
.order_by(DetectionJob.id.desc())
|
| 93 |
+
.limit(200)
|
| 94 |
+
.all()
|
| 95 |
+
)
|
| 96 |
+
if is_auto_schedule_job(j)
|
| 97 |
+
]
|
| 98 |
+
filt = DetectionRun.user_id == user.id
|
| 99 |
+
if auto_ids:
|
| 100 |
+
filt = or_(filt, DetectionRun.id.in_(auto_ids))
|
| 101 |
+
return filt
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _load_dataset(db: Session, user: User) -> list[dict]:
|
| 105 |
+
"""One query for every accessible run + one grouped query for
|
| 106 |
+
classification counts; every endpoint below aggregates from this in
|
| 107 |
+
Python β mirrors the simple query style already used by
|
| 108 |
+
admin_routes.admin_status(). Runs with a blank zone/village are bucketed
|
| 109 |
+
under "Unassigned" so totals reconcile.
|
| 110 |
+
"""
|
| 111 |
+
runs = (
|
| 112 |
+
db.query(DetectionRun)
|
| 113 |
+
.filter(_accessible_run_filter(db, user))
|
| 114 |
+
.order_by(DetectionRun.created_at.asc())
|
| 115 |
+
.all()
|
| 116 |
+
)
|
| 117 |
+
counts = bulk_classification_counts(db)
|
| 118 |
+
# Same role resolution as dda_auth.get_user_role (raw User.role string,
|
| 119 |
+
# falling back to "analyst") but bulk-loaded to avoid a query per run.
|
| 120 |
+
roles_by_user = {uid: (role or DEFAULT_ROLE) for uid, role in db.query(User.id, User.role).all()}
|
| 121 |
+
rows = []
|
| 122 |
+
for run in runs:
|
| 123 |
+
c = counts.get(run.id, {})
|
| 124 |
+
classified = c.get("confirmed", 0) + c.get("false_positive", 0) + c.get("submitted", 0)
|
| 125 |
+
unclassified = max(0, (run.regions_count or 0) - classified)
|
| 126 |
+
rows.append({
|
| 127 |
+
"run": run,
|
| 128 |
+
"zone": (run.zone or "").strip() or UNASSIGNED,
|
| 129 |
+
"village": (run.village or "").strip() or UNASSIGNED,
|
| 130 |
+
"role": roles_by_user.get(run.user_id, DEFAULT_ROLE),
|
| 131 |
+
"confirmed": c.get("confirmed", 0),
|
| 132 |
+
"falsePositive": c.get("false_positive", 0),
|
| 133 |
+
"submitted": c.get("submitted", 0),
|
| 134 |
+
"unclassified": unclassified,
|
| 135 |
+
})
|
| 136 |
+
return rows
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/dashboard/summary")
|
| 140 |
+
def dashboard_summary(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 141 |
+
_require_dda()
|
| 142 |
+
rows = _load_dataset(db, user)
|
| 143 |
+
|
| 144 |
+
total_detected = sum((r["run"].regions_count or 0) for r in rows)
|
| 145 |
+
pct_values = [r["run"].change_percentage for r in rows if r["run"].change_percentage is not None]
|
| 146 |
+
zones = {r["zone"] for r in rows}
|
| 147 |
+
villages = {(r["zone"], r["village"]) for r in rows}
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"totalRuns": len(rows),
|
| 151 |
+
"totalDetectedChanges": total_detected,
|
| 152 |
+
"totalUnclassified": sum(r["unclassified"] for r in rows),
|
| 153 |
+
"totalConfirmed": sum(r["confirmed"] for r in rows),
|
| 154 |
+
"totalFalsePositive": sum(r["falsePositive"] for r in rows),
|
| 155 |
+
"totalSubmitted": sum(r["submitted"] for r in rows),
|
| 156 |
+
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 157 |
+
"totalRegions": len(zones),
|
| 158 |
+
"totalVillages": len(villages),
|
| 159 |
+
"jobsQueued": db.query(DetectionJob).filter(DetectionJob.status == "queued", DetectionJob.created_by == user.id).count(),
|
| 160 |
+
"jobsRunning": db.query(DetectionJob).filter(DetectionJob.status == "running", DetectionJob.created_by == user.id).count(),
|
| 161 |
+
"latestRun": _run_summary(rows[-1]) if rows else None,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.get("/dashboard/regions")
|
| 166 |
+
def dashboard_regions(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 167 |
+
_require_dda()
|
| 168 |
+
rows = _load_dataset(db, user)
|
| 169 |
+
by_zone: dict[str, list] = defaultdict(list)
|
| 170 |
+
for r in rows:
|
| 171 |
+
by_zone[r["zone"]].append(r)
|
| 172 |
+
|
| 173 |
+
out = []
|
| 174 |
+
for zone, items in by_zone.items():
|
| 175 |
+
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
| 176 |
+
out.append({
|
| 177 |
+
"zone": zone,
|
| 178 |
+
"runsCount": len(items),
|
| 179 |
+
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 180 |
+
"unclassified": sum(i["unclassified"] for i in items),
|
| 181 |
+
"confirmed": sum(i["confirmed"] for i in items),
|
| 182 |
+
"falsePositive": sum(i["falsePositive"] for i in items),
|
| 183 |
+
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 184 |
+
"villagesCount": len({i["village"] for i in items}),
|
| 185 |
+
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 186 |
+
})
|
| 187 |
+
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 188 |
+
return {"regions": out}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.get("/dashboard/roles")
|
| 192 |
+
def dashboard_roles(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 193 |
+
"""Detection activity grouped by the role of the user who ran it."""
|
| 194 |
+
_require_dda()
|
| 195 |
+
rows = _load_dataset(db, user)
|
| 196 |
+
by_role: dict[str, list] = defaultdict(list)
|
| 197 |
+
for r in rows:
|
| 198 |
+
by_role[r["role"]].append(r)
|
| 199 |
+
|
| 200 |
+
def _rank(role: str) -> int:
|
| 201 |
+
return ROLE_ORDER.index(role) if role in ROLE_ORDER else len(ROLE_ORDER)
|
| 202 |
+
|
| 203 |
+
out = []
|
| 204 |
+
for role, items in by_role.items():
|
| 205 |
+
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
| 206 |
+
out.append({
|
| 207 |
+
"role": role,
|
| 208 |
+
"runsCount": len(items),
|
| 209 |
+
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 210 |
+
"unclassified": sum(i["unclassified"] for i in items),
|
| 211 |
+
"confirmed": sum(i["confirmed"] for i in items),
|
| 212 |
+
"falsePositive": sum(i["falsePositive"] for i in items),
|
| 213 |
+
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 214 |
+
"usersCount": len({i["run"].user_id for i in items}),
|
| 215 |
+
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 216 |
+
})
|
| 217 |
+
out.sort(key=lambda x: (_rank(x["role"]), x["role"]))
|
| 218 |
+
return {"roles": out}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/dashboard/villages")
|
| 222 |
+
def dashboard_villages(
|
| 223 |
+
zone: Optional[str] = Query(None),
|
| 224 |
+
db: Session = Depends(get_db),
|
| 225 |
+
user: User = Depends(current_dda_user),
|
| 226 |
+
):
|
| 227 |
+
_require_dda()
|
| 228 |
+
rows = _load_dataset(db, user)
|
| 229 |
+
if zone:
|
| 230 |
+
rows = [r for r in rows if r["zone"] == zone]
|
| 231 |
+
by_village: dict[tuple, list] = defaultdict(list)
|
| 232 |
+
for r in rows:
|
| 233 |
+
by_village[(r["zone"], r["village"])].append(r)
|
| 234 |
+
|
| 235 |
+
out = []
|
| 236 |
+
for (z, v), items in by_village.items():
|
| 237 |
+
pct_values = [i["run"].change_percentage for i in items if i["run"].change_percentage is not None]
|
| 238 |
+
out.append({
|
| 239 |
+
"zone": z,
|
| 240 |
+
"village": v,
|
| 241 |
+
"runsCount": len(items),
|
| 242 |
+
"detectedChanges": sum((i["run"].regions_count or 0) for i in items),
|
| 243 |
+
"unclassified": sum(i["unclassified"] for i in items),
|
| 244 |
+
"confirmed": sum(i["confirmed"] for i in items),
|
| 245 |
+
"falsePositive": sum(i["falsePositive"] for i in items),
|
| 246 |
+
"avgChangePercentage": round(sum(pct_values) / len(pct_values), 2) if pct_values else None,
|
| 247 |
+
"lastDetectionAt": _isoformat_ist(max(i["run"].created_at for i in items)),
|
| 248 |
+
})
|
| 249 |
+
out.sort(key=lambda x: x["runsCount"], reverse=True)
|
| 250 |
+
return {"villages": out}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@router.get("/dashboard/trend")
|
| 254 |
+
def dashboard_trend(
|
| 255 |
+
months: int = Query(6, ge=1, le=24),
|
| 256 |
+
db: Session = Depends(get_db),
|
| 257 |
+
user: User = Depends(current_dda_user),
|
| 258 |
+
):
|
| 259 |
+
_require_dda()
|
| 260 |
+
rows = _load_dataset(db, user)
|
| 261 |
+
|
| 262 |
+
now = datetime.now(timezone.utc)
|
| 263 |
+
keys = []
|
| 264 |
+
y, m = now.year, now.month
|
| 265 |
+
for _ in range(months):
|
| 266 |
+
keys.append((y, m))
|
| 267 |
+
m -= 1
|
| 268 |
+
if m == 0:
|
| 269 |
+
m, y = 12, y - 1
|
| 270 |
+
keys.reverse()
|
| 271 |
+
|
| 272 |
+
counts = {k: {"runsCount": 0, "detectedChanges": 0} for k in keys}
|
| 273 |
+
for r in rows:
|
| 274 |
+
dt = r["run"].created_at
|
| 275 |
+
if dt is None:
|
| 276 |
+
continue
|
| 277 |
+
if dt.tzinfo is None:
|
| 278 |
+
dt = dt.replace(tzinfo=timezone.utc)
|
| 279 |
+
key = (dt.year, dt.month)
|
| 280 |
+
if key in counts:
|
| 281 |
+
counts[key]["runsCount"] += 1
|
| 282 |
+
counts[key]["detectedChanges"] += (r["run"].regions_count or 0)
|
| 283 |
+
|
| 284 |
+
buckets = [
|
| 285 |
+
{
|
| 286 |
+
"period": f"{y}-{m:02d}",
|
| 287 |
+
"label": f"{MONTH_NAMES[m]} {y}",
|
| 288 |
+
"runsCount": counts[(y, m)]["runsCount"],
|
| 289 |
+
"detectedChanges": counts[(y, m)]["detectedChanges"],
|
| 290 |
+
}
|
| 291 |
+
for (y, m) in keys
|
| 292 |
+
]
|
| 293 |
+
return {"buckets": buckets}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@router.get("/dashboard/location")
|
| 297 |
+
def dashboard_location(
|
| 298 |
+
zone: str = Query(...),
|
| 299 |
+
village: str = Query(...),
|
| 300 |
+
db: Session = Depends(get_db),
|
| 301 |
+
user: User = Depends(current_dda_user),
|
| 302 |
+
):
|
| 303 |
+
_require_dda()
|
| 304 |
+
rows = _load_dataset(db, user)
|
| 305 |
+
items = [r for r in rows if r["zone"] == zone and r["village"] == village]
|
| 306 |
+
items.sort(key=lambda r: r["run"].created_at)
|
| 307 |
+
|
| 308 |
+
history = [_run_summary(i) for i in items]
|
| 309 |
+
latest = history[-1] if history else None
|
| 310 |
+
previous = history[-2] if len(history) > 1 else None
|
| 311 |
+
|
| 312 |
+
comparison = None
|
| 313 |
+
if latest and previous:
|
| 314 |
+
comparison = {
|
| 315 |
+
"changePercentageDelta": (
|
| 316 |
+
round(latest["changePercentage"] - previous["changePercentage"], 2)
|
| 317 |
+
if latest["changePercentage"] is not None and previous["changePercentage"] is not None
|
| 318 |
+
else None
|
| 319 |
+
),
|
| 320 |
+
"regionsCountDelta": latest["regionsCount"] - previous["regionsCount"],
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
"zone": zone,
|
| 325 |
+
"village": village,
|
| 326 |
+
"runsCount": len(items),
|
| 327 |
+
"latest": latest,
|
| 328 |
+
"previous": previous,
|
| 329 |
+
"comparison": comparison,
|
| 330 |
+
"history": list(reversed(history)),
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
@router.get("/dashboard/recent-reports")
|
| 335 |
+
def dashboard_recent_reports(
|
| 336 |
+
limit: int = Query(8, ge=1, le=50),
|
| 337 |
+
db: Session = Depends(get_db),
|
| 338 |
+
user: User = Depends(current_dda_user),
|
| 339 |
+
):
|
| 340 |
+
_require_dda()
|
| 341 |
+
rows = _load_dataset(db, user)
|
| 342 |
+
rows.sort(key=lambda r: r["run"].created_at, reverse=True)
|
| 343 |
+
return {"reports": [_run_summary(r) for r in rows[:limit]]}
|
app/dda/dda_auth.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vedangsoft session users and RBAC (Phase 7)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends, HTTPException, Request
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from ..auth import get_user_from_token
|
| 11 |
+
from ..database import get_db
|
| 12 |
+
from ..models import User
|
| 13 |
+
|
| 14 |
+
COOKIE_NAME = "satellite_token"
|
| 15 |
+
|
| 16 |
+
ROLE_RANK = {
|
| 17 |
+
"viewer": 0,
|
| 18 |
+
"uploader": 1,
|
| 19 |
+
"analyst": 2,
|
| 20 |
+
"admin": 3,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
TRAINING_EXPORT_KEY = os.environ.get("DDA_TRAINING_EXPORT_KEY", "").strip()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_user_role(db: Session, user: User) -> str:
|
| 27 |
+
role = getattr(user, "role", None)
|
| 28 |
+
if role:
|
| 29 |
+
return str(role)
|
| 30 |
+
try:
|
| 31 |
+
from sqlalchemy import text as sa_text
|
| 32 |
+
row = db.execute(
|
| 33 |
+
sa_text("SELECT role FROM users WHERE id = :uid"),
|
| 34 |
+
{"uid": user.id},
|
| 35 |
+
).fetchone()
|
| 36 |
+
if row and row[0]:
|
| 37 |
+
return str(row[0])
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
return "analyst"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_dda_user(request: Request, db: Session) -> User:
|
| 44 |
+
"""Resolve the logged-in user from a Bearer token or the auth cookie.
|
| 45 |
+
|
| 46 |
+
Login is required β no anonymous per-browser session or shared-guest
|
| 47 |
+
fallback anymore (removed; see git history for the prior behavior).
|
| 48 |
+
"""
|
| 49 |
+
auth_header = request.headers.get("authorization", "")
|
| 50 |
+
if auth_header.lower().startswith("bearer "):
|
| 51 |
+
token = auth_header.split(" ", 1)[1].strip()
|
| 52 |
+
user = get_user_from_token(token, db)
|
| 53 |
+
if user:
|
| 54 |
+
return user
|
| 55 |
+
|
| 56 |
+
token = request.cookies.get(COOKIE_NAME)
|
| 57 |
+
if token:
|
| 58 |
+
user = get_user_from_token(token, db)
|
| 59 |
+
if user and not user.email.startswith("__guest__"):
|
| 60 |
+
return user
|
| 61 |
+
|
| 62 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def current_dda_user(request: Request, db: Session = Depends(get_db)) -> User:
|
| 66 |
+
return get_dda_user(request, db)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _role_rank_from_db(db: Session, role_name: str) -> Optional[int]:
|
| 70 |
+
"""Rank now lives on the dda_roles table (app/dda/rbac/models.py) so admins
|
| 71 |
+
can add/reorder roles without a code change. Falls back to None (caller
|
| 72 |
+
uses ROLE_RANK) if the table isn't seeded yet or the name isn't found."""
|
| 73 |
+
try:
|
| 74 |
+
from .rbac.models import Role
|
| 75 |
+
row = db.query(Role).filter(Role.name == role_name).first()
|
| 76 |
+
return row.rank if row else None
|
| 77 |
+
except Exception:
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def require_min_role(user: User, db: Session, minimum: str) -> None:
|
| 82 |
+
role = get_user_role(db, user)
|
| 83 |
+
need = _role_rank_from_db(db, minimum)
|
| 84 |
+
if need is None:
|
| 85 |
+
need = ROLE_RANK.get(minimum, 99)
|
| 86 |
+
have = _role_rank_from_db(db, role)
|
| 87 |
+
if have is None:
|
| 88 |
+
have = ROLE_RANK.get(role, 0)
|
| 89 |
+
if have < need:
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=403,
|
| 92 |
+
detail=f"Role '{role}' cannot perform this action (requires {minimum} or higher).",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def require_admin_or_key(request: Request, user: User, db: Session) -> None:
|
| 97 |
+
key = request.headers.get("x-dda-training-key", "").strip()
|
| 98 |
+
if TRAINING_EXPORT_KEY and key == TRAINING_EXPORT_KEY:
|
| 99 |
+
return
|
| 100 |
+
require_min_role(user, db, "admin")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def seed_dda_admin(db: Session) -> None:
|
| 104 |
+
"""Optional admin account from env (dev/UAT)."""
|
| 105 |
+
email = os.environ.get("DDA_ADMIN_EMAIL", "").strip()
|
| 106 |
+
password = os.environ.get("DDA_ADMIN_PASSWORD", "").strip()
|
| 107 |
+
if not email or not password:
|
| 108 |
+
return
|
| 109 |
+
from ..auth import get_password_hash, get_user_by_email
|
| 110 |
+
user = get_user_by_email(db, email)
|
| 111 |
+
if not user:
|
| 112 |
+
from ..auth import normalize_phone
|
| 113 |
+
admin_phone = normalize_phone(os.environ.get("DDA_ADMIN_PHONE", ""))
|
| 114 |
+
user = User(
|
| 115 |
+
email=email,
|
| 116 |
+
hashed_password=get_password_hash(password),
|
| 117 |
+
full_name=os.environ.get("DDA_ADMIN_NAME", "Vedangsoft Admin"),
|
| 118 |
+
phone=admin_phone,
|
| 119 |
+
)
|
| 120 |
+
db.add(user)
|
| 121 |
+
db.commit()
|
| 122 |
+
db.refresh(user)
|
| 123 |
+
try:
|
| 124 |
+
from sqlalchemy import text as sa_text
|
| 125 |
+
db.execute(
|
| 126 |
+
sa_text("UPDATE users SET role = 'admin' WHERE id = :uid"),
|
| 127 |
+
{"uid": user.id},
|
| 128 |
+
)
|
| 129 |
+
db.commit()
|
| 130 |
+
except Exception:
|
| 131 |
+
db.rollback()
|
app/dda/dept_export.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Department export adapter β API or file fallback (FR-08)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import csv
|
| 5 |
+
import io
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from ..models import DetectionRun
|
| 14 |
+
from .geo_regions import region_lat_lng
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
DEPT_API_URL = os.environ.get("DEPT_API_URL", "").strip()
|
| 19 |
+
DEPT_API_KEY = os.environ.get("DEPT_API_KEY", "").strip()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ExportResult:
|
| 24 |
+
ok: bool
|
| 25 |
+
mode: str # api | file
|
| 26 |
+
message: str
|
| 27 |
+
submitted_count: int = 0
|
| 28 |
+
download_path: Optional[str] = None
|
| 29 |
+
detail: Optional[Dict[str, Any]] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def regions_to_csv_rows(run: DetectionRun, regions: List[dict]) -> str:
|
| 33 |
+
"""Build CSV string for export."""
|
| 34 |
+
buf = io.StringIO()
|
| 35 |
+
writer = csv.writer(buf)
|
| 36 |
+
writer.writerow([
|
| 37 |
+
"run_id", "run_title", "zone", "village", "region_id",
|
| 38 |
+
"dda_change_type", "internal_type", "confidence", "area_px",
|
| 39 |
+
"latitude", "longitude", "area_sq_m", "review_status", "notes",
|
| 40 |
+
])
|
| 41 |
+
for r in regions:
|
| 42 |
+
lat, lng = region_lat_lng(r)
|
| 43 |
+
writer.writerow([
|
| 44 |
+
run.id,
|
| 45 |
+
run.title,
|
| 46 |
+
run.zone or "",
|
| 47 |
+
run.village or "",
|
| 48 |
+
r.get("id", ""),
|
| 49 |
+
r.get("ddaChangeType") or "",
|
| 50 |
+
r.get("internalObjectType") or r.get("objectType") or "",
|
| 51 |
+
f'{(r.get("confidence") or 0):.4f}',
|
| 52 |
+
r.get("area", ""),
|
| 53 |
+
lat if lat is not None else "",
|
| 54 |
+
lng if lng is not None else "",
|
| 55 |
+
r.get("areaSqM") if r.get("areaSqM") is not None else "",
|
| 56 |
+
r.get("reviewStatus") or "pending",
|
| 57 |
+
r.get("reviewNotes") or "",
|
| 58 |
+
])
|
| 59 |
+
return buf.getvalue()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class FileExporter:
|
| 63 |
+
"""Fallback β client downloads CSV via API."""
|
| 64 |
+
|
| 65 |
+
def submit(self, run: DetectionRun, regions: List[dict]) -> ExportResult:
|
| 66 |
+
if not regions:
|
| 67 |
+
return ExportResult(
|
| 68 |
+
ok=False,
|
| 69 |
+
mode="file",
|
| 70 |
+
message="No confirmed regions to export.",
|
| 71 |
+
submitted_count=0,
|
| 72 |
+
)
|
| 73 |
+
return ExportResult(
|
| 74 |
+
ok=True,
|
| 75 |
+
mode="file",
|
| 76 |
+
message=f"{len(regions)} confirmed region(s) ready for CSV download.",
|
| 77 |
+
submitted_count=len(regions),
|
| 78 |
+
download_path=f"/api/dda/reports/{run.id}/export.csv?confirmed=1",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ApiExporter:
|
| 83 |
+
"""POST confirmed changes to departmental API."""
|
| 84 |
+
|
| 85 |
+
def __init__(self, url: str, api_key: str = ""):
|
| 86 |
+
self.url = url.rstrip("/")
|
| 87 |
+
self.api_key = api_key
|
| 88 |
+
|
| 89 |
+
def submit(self, run: DetectionRun, regions: List[dict]) -> ExportResult:
|
| 90 |
+
if not regions:
|
| 91 |
+
return ExportResult(
|
| 92 |
+
ok=False,
|
| 93 |
+
mode="api",
|
| 94 |
+
message="No confirmed regions to submit.",
|
| 95 |
+
)
|
| 96 |
+
payload = {
|
| 97 |
+
"runId": run.id,
|
| 98 |
+
"title": run.title,
|
| 99 |
+
"zone": run.zone or "",
|
| 100 |
+
"village": run.village or "",
|
| 101 |
+
"changePercentage": run.change_percentage,
|
| 102 |
+
"regions": [
|
| 103 |
+
{
|
| 104 |
+
"id": r.get("id"),
|
| 105 |
+
"ddaChangeType": r.get("ddaChangeType"),
|
| 106 |
+
"objectType": r.get("objectType"),
|
| 107 |
+
"confidence": r.get("confidence"),
|
| 108 |
+
"area": r.get("area"),
|
| 109 |
+
"latitude": r.get("latitude") or (r.get("latLng") or {}).get("lat"),
|
| 110 |
+
"longitude": r.get("longitude") or (r.get("latLng") or {}).get("lng"),
|
| 111 |
+
"areaSqM": r.get("areaSqM"),
|
| 112 |
+
}
|
| 113 |
+
for r in regions
|
| 114 |
+
],
|
| 115 |
+
}
|
| 116 |
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
| 117 |
+
if self.api_key:
|
| 118 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 119 |
+
try:
|
| 120 |
+
resp = requests.post(self.url, json=payload, headers=headers, timeout=60)
|
| 121 |
+
if 200 <= resp.status_code < 300:
|
| 122 |
+
detail = None
|
| 123 |
+
try:
|
| 124 |
+
detail = resp.json()
|
| 125 |
+
except Exception:
|
| 126 |
+
detail = {"body": resp.text[:500]}
|
| 127 |
+
return ExportResult(
|
| 128 |
+
ok=True,
|
| 129 |
+
mode="api",
|
| 130 |
+
message=f"Submitted {len(regions)} confirmed region(s) to departmental system.",
|
| 131 |
+
submitted_count=len(regions),
|
| 132 |
+
detail=detail,
|
| 133 |
+
)
|
| 134 |
+
return ExportResult(
|
| 135 |
+
ok=False,
|
| 136 |
+
mode="api",
|
| 137 |
+
message=f"Department API returned {resp.status_code}: {resp.text[:200]}",
|
| 138 |
+
)
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
logger.exception("Department API submit failed")
|
| 141 |
+
return ExportResult(
|
| 142 |
+
ok=False,
|
| 143 |
+
mode="api",
|
| 144 |
+
message=f"Department API error: {exc}",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_exporter() -> FileExporter | ApiExporter:
|
| 149 |
+
if DEPT_API_URL:
|
| 150 |
+
return ApiExporter(DEPT_API_URL, DEPT_API_KEY)
|
| 151 |
+
return FileExporter()
|
app/dda/detect_service.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run change detection and persist results (shared by upload + library paths)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from datetime import timezone
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from PIL import Image
|
| 13 |
+
from sqlalchemy.orm import Session
|
| 14 |
+
|
| 15 |
+
from ..auth import get_or_create_guest_user
|
| 16 |
+
from ..database import DATA_DIR
|
| 17 |
+
from ..models import DetectionRun
|
| 18 |
+
from ..notifier import send_notification
|
| 19 |
+
from .geo_regions import enrich_regions_geo, resolve_geo_context
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
OVERLAYS_DIR = DATA_DIR / "overlays"
|
| 24 |
+
THUMB_MAX_SIZE = 200
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
MAX_POLYGON_VERTICES = 100
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _serialize_polygon(raw) -> Optional[list]:
|
| 31 |
+
"""Normalize an engine polygon to ``[[x, y], ...]`` ints, or None.
|
| 32 |
+
|
| 33 |
+
The engine emits an outer ring in detection-image pixels. Defensively caps
|
| 34 |
+
the vertex count (the extractor also limits, but region JSON is stored per
|
| 35 |
+
run and a runaway contour would bloat every report that loads it) by
|
| 36 |
+
keeping evenly-spaced vertices so the footprint's shape survives.
|
| 37 |
+
"""
|
| 38 |
+
if not raw:
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
pts = [(int(round(float(p[0]))), int(round(float(p[1])))) for p in raw]
|
| 42 |
+
except (TypeError, ValueError, IndexError):
|
| 43 |
+
logger.warning("Ignoring malformed polygon on region")
|
| 44 |
+
return None
|
| 45 |
+
if len(pts) < 3:
|
| 46 |
+
return None
|
| 47 |
+
if len(pts) > MAX_POLYGON_VERTICES:
|
| 48 |
+
step = len(pts) / float(MAX_POLYGON_VERTICES)
|
| 49 |
+
pts = [pts[min(len(pts) - 1, int(i * step))] for i in range(MAX_POLYGON_VERTICES)]
|
| 50 |
+
return [[x, y] for x, y in pts]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _serialize_regions(change_regions) -> list:
|
| 54 |
+
out = []
|
| 55 |
+
for r in change_regions:
|
| 56 |
+
entry = {
|
| 57 |
+
"id": int(r["id"]),
|
| 58 |
+
"area": int(r["area"]),
|
| 59 |
+
"center": {"x": round(float(r["center"][0])), "y": round(float(r["center"][1]))},
|
| 60 |
+
"bbox": {
|
| 61 |
+
"x": int(r["bbox"][0]),
|
| 62 |
+
"y": int(r["bbox"][1]),
|
| 63 |
+
"w": int(r["bbox"][2]),
|
| 64 |
+
"h": int(r["bbox"][3]),
|
| 65 |
+
},
|
| 66 |
+
"objectType": str(r["object_type"]),
|
| 67 |
+
"confidence": float(r["confidence"]),
|
| 68 |
+
"severity": r.get("severity", "minor"),
|
| 69 |
+
"subType": r.get("sub_type"),
|
| 70 |
+
"subTypeConfidence": float(r["sub_type_confidence"])
|
| 71 |
+
if r.get("sub_type_confidence") is not None
|
| 72 |
+
else None,
|
| 73 |
+
"estimatedStories": r.get("estimated_stories"),
|
| 74 |
+
"estimatedHeightM": float(r["estimated_height_m"])
|
| 75 |
+
if r.get("estimated_height_m") is not None
|
| 76 |
+
else None,
|
| 77 |
+
"constructionStage": r.get("construction_stage"),
|
| 78 |
+
}
|
| 79 |
+
# Polygon is optional: regions without one serialize exactly as before,
|
| 80 |
+
# and every consumer falls back to bbox.
|
| 81 |
+
polygon = _serialize_polygon(r.get("polygon"))
|
| 82 |
+
if polygon:
|
| 83 |
+
entry["polygon"] = polygon
|
| 84 |
+
out.append(entry)
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _filter_weak_other_regions(regions: list) -> list:
|
| 89 |
+
"""Optionally mark tiny low-conf Other blobs β never drop report rows.
|
| 90 |
+
|
| 91 |
+
History: a hard conf floor (DDA_OTHER_MIN_CONF=0.5) deleted 13/15 Grid_54
|
| 92 |
+
regions from ``regions_json`` while the overlay still drew them, so the
|
| 93 |
+
report disagreed with detection. Regions must match the engine list; ops
|
| 94 |
+
can hide noise in review instead of deleting at save time.
|
| 95 |
+
|
| 96 |
+
Set ``DDA_OTHER_MIN_CONF>0`` and ``DDA_SUPPRESS_WEAK_OTHER=1`` to flag
|
| 97 |
+
(not remove) tiny Other blobs with ``suppressed=true``.
|
| 98 |
+
"""
|
| 99 |
+
import os
|
| 100 |
+
raw = os.environ.get("DDA_OTHER_MIN_CONF", "0").strip()
|
| 101 |
+
try:
|
| 102 |
+
floor = float(raw)
|
| 103 |
+
except ValueError:
|
| 104 |
+
floor = 0.0
|
| 105 |
+
try:
|
| 106 |
+
min_area = int(float(os.environ.get("DDA_OTHER_MIN_AREA", "1200")))
|
| 107 |
+
except ValueError:
|
| 108 |
+
min_area = 1200
|
| 109 |
+
suppress = os.environ.get("DDA_SUPPRESS_WEAK_OTHER", "").strip().lower() in (
|
| 110 |
+
"1", "true", "yes", "on",
|
| 111 |
+
)
|
| 112 |
+
if floor <= 0:
|
| 113 |
+
for r in regions:
|
| 114 |
+
r.setdefault("suppressed", False)
|
| 115 |
+
return regions
|
| 116 |
+
|
| 117 |
+
kept = []
|
| 118 |
+
flagged = 0
|
| 119 |
+
for r in regions:
|
| 120 |
+
dda = (r.get("ddaChangeType") or "").strip()
|
| 121 |
+
conf = float(r.get("confidence") or 0.0)
|
| 122 |
+
area = int(r.get("area") or 0)
|
| 123 |
+
weak = dda == "Other" and conf < floor and area < min_area
|
| 124 |
+
r = dict(r)
|
| 125 |
+
r["suppressed"] = bool(weak and suppress)
|
| 126 |
+
if weak and suppress:
|
| 127 |
+
flagged += 1
|
| 128 |
+
# Never drop β always keep in report JSON
|
| 129 |
+
kept.append(r)
|
| 130 |
+
if flagged:
|
| 131 |
+
logger.info("Flagged %d weak Other regions as suppressed (still in report)", flagged)
|
| 132 |
+
return kept
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _isoformat_ist(dt):
|
| 136 |
+
from datetime import timedelta
|
| 137 |
+
if dt is None:
|
| 138 |
+
return None
|
| 139 |
+
_IST = timezone(timedelta(hours=5, minutes=30))
|
| 140 |
+
if dt.tzinfo is None:
|
| 141 |
+
from datetime import timezone as tz
|
| 142 |
+
dt = dt.replace(tzinfo=tz.utc)
|
| 143 |
+
return dt.astimezone(_IST).isoformat()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _alignment_warning(before_pil, after_pil, before_tif, after_tif):
|
| 147 |
+
"""Operator-facing warning when a pair is identical or poorly aligned.
|
| 148 |
+
|
| 149 |
+
Runs the pre-detection guard (``app.dda.pair_align``) to catch the two
|
| 150 |
+
silent-failure modes that otherwise yield an empty or garbage change mask
|
| 151 |
+
with no explanation: (1) the same image uploaded twice, and (2) raw drone
|
| 152 |
+
frames from different viewpoints that never register onto a common grid.
|
| 153 |
+
Returns ``None`` when the pair looks fine or the check cannot run, so this
|
| 154 |
+
can never block or crash a detection job.
|
| 155 |
+
"""
|
| 156 |
+
try:
|
| 157 |
+
import numpy as np
|
| 158 |
+
|
| 159 |
+
from .pair_align import are_identical, assess_alignment, geo_align_pair
|
| 160 |
+
|
| 161 |
+
b = np.asarray(before_pil.convert("RGB"))
|
| 162 |
+
a = np.asarray(after_pil.convert("RGB"))
|
| 163 |
+
if b.shape == a.shape and are_identical(b, a):
|
| 164 |
+
return ("Before and after are the same image (identical pixels). There is "
|
| 165 |
+
"no change to detect - please check that two different dates were "
|
| 166 |
+
"selected.")
|
| 167 |
+
# The NCC misalignment check only makes sense on georeferenced pairs, where
|
| 168 |
+
# both can be placed on a common grid. For plain uploads the engine's own
|
| 169 |
+
# SIFT/ECC registration handles alignment, so comparing the raw (pre-
|
| 170 |
+
# registration) frames here would raise false alarms.
|
| 171 |
+
if before_tif and after_tif:
|
| 172 |
+
aligned = geo_align_pair(Path(before_tif), Path(after_tif))
|
| 173 |
+
if aligned is not None:
|
| 174 |
+
bb, aa, _overlap = aligned
|
| 175 |
+
status, ncc = assess_alignment(bb, aa)
|
| 176 |
+
if status == "low_quality":
|
| 177 |
+
return (f"The before/after images do not align well (NCC={ncc:.2f}). "
|
| 178 |
+
"They look like raw drone frames from different viewpoints "
|
| 179 |
+
"rather than orthomosaics on a common grid, so detected "
|
| 180 |
+
"changes may be unreliable. For accurate results, export both "
|
| 181 |
+
"dates as north-up orthomosaics on the same grid.")
|
| 182 |
+
return None
|
| 183 |
+
except Exception as exc:
|
| 184 |
+
logger.warning("Alignment guard skipped: %s", exc)
|
| 185 |
+
return None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def run_detection_and_save(
|
| 189 |
+
db: Session,
|
| 190 |
+
before_pil: Image.Image,
|
| 191 |
+
after_pil: Image.Image,
|
| 192 |
+
*,
|
| 193 |
+
method: str = "AI-Based Deep Learning",
|
| 194 |
+
title: str = "Untitled run",
|
| 195 |
+
zone: str = "",
|
| 196 |
+
village: str = "",
|
| 197 |
+
enable_registration: bool = True,
|
| 198 |
+
enable_normalization: bool = True,
|
| 199 |
+
detection_sensitivity: float = 0.5,
|
| 200 |
+
min_region_area: Optional[int] = None,
|
| 201 |
+
notify_email: Optional[str] = None,
|
| 202 |
+
max_size: Optional[int] = None,
|
| 203 |
+
geo_bounds_path: Optional[Path] = None,
|
| 204 |
+
comparison_file: Optional[Path] = None,
|
| 205 |
+
base_path: str = "",
|
| 206 |
+
comparison_path: str = "",
|
| 207 |
+
user_id: Optional[int] = None,
|
| 208 |
+
job_id: Optional[int] = None,
|
| 209 |
+
gsd_debug: Optional[dict] = None,
|
| 210 |
+
shape_mode: str = "polygon",
|
| 211 |
+
preflight_warnings: Optional[list] = None,
|
| 212 |
+
) -> dict:
|
| 213 |
+
from ..detection_engine import run_detection
|
| 214 |
+
from .job_progress import update_job_progress
|
| 215 |
+
|
| 216 |
+
def _report(pct: int, stage: str) -> None:
|
| 217 |
+
if job_id is not None:
|
| 218 |
+
update_job_progress(job_id, pct, stage)
|
| 219 |
+
|
| 220 |
+
if user_id:
|
| 221 |
+
from ..auth import get_user_by_id
|
| 222 |
+
user = get_user_by_id(db, user_id)
|
| 223 |
+
if not user:
|
| 224 |
+
user = get_or_create_guest_user(db)
|
| 225 |
+
else:
|
| 226 |
+
user = get_or_create_guest_user(db)
|
| 227 |
+
db.commit()
|
| 228 |
+
detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
|
| 229 |
+
if min_region_area is not None:
|
| 230 |
+
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 231 |
+
|
| 232 |
+
def _on_engine_progress(engine_pct: int, stage: str) -> None:
|
| 233 |
+
# Map engine 0β100% into job 15β78%
|
| 234 |
+
job_pct = 15 + int(engine_pct * 0.63)
|
| 235 |
+
_report(job_pct, stage)
|
| 236 |
+
|
| 237 |
+
_report(15, "Running detection")
|
| 238 |
+
|
| 239 |
+
def _geotiff_path(p: Optional[Path]) -> Optional[str]:
|
| 240 |
+
if p is not None and str(p).lower().endswith((".tif", ".tiff")):
|
| 241 |
+
return str(p)
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
change_mask, result_image, stats, change_regions = run_detection(
|
| 245 |
+
before_pil,
|
| 246 |
+
after_pil,
|
| 247 |
+
method=method,
|
| 248 |
+
enable_registration=enable_registration,
|
| 249 |
+
enable_normalization=enable_normalization,
|
| 250 |
+
detection_sensitivity=detection_sensitivity,
|
| 251 |
+
min_region_area=min_region_area,
|
| 252 |
+
max_size=max_size,
|
| 253 |
+
on_progress=_on_engine_progress,
|
| 254 |
+
before_path=_geotiff_path(geo_bounds_path),
|
| 255 |
+
after_path=_geotiff_path(comparison_file),
|
| 256 |
+
shape_mode=shape_mode,
|
| 257 |
+
)
|
| 258 |
+
# Record the choice so the viewer can match the baked overlay's style.
|
| 259 |
+
if isinstance(stats.get("params"), dict):
|
| 260 |
+
stats["params"]["shape_mode"] = shape_mode
|
| 261 |
+
|
| 262 |
+
# Pre-detection guard: surface identical / poorly-aligned pairs to the operator
|
| 263 |
+
# instead of returning a silent empty or garbage mask (the UI already renders
|
| 264 |
+
# stats["alignment_warning"] as a banner). Never overrides an existing warning.
|
| 265 |
+
if not stats.get("alignment_warning"):
|
| 266 |
+
align_warn = _alignment_warning(
|
| 267 |
+
before_pil, after_pil,
|
| 268 |
+
_geotiff_path(geo_bounds_path), _geotiff_path(comparison_file),
|
| 269 |
+
)
|
| 270 |
+
if align_warn:
|
| 271 |
+
stats["alignment_warning"] = align_warn
|
| 272 |
+
logger.info("Alignment guard flagged pair: %s", align_warn)
|
| 273 |
+
|
| 274 |
+
if gsd_debug and isinstance(stats.get("threshold_debug"), dict):
|
| 275 |
+
stats["threshold_debug"]["gsdHarmonization"] = gsd_debug
|
| 276 |
+
|
| 277 |
+
if preflight_warnings:
|
| 278 |
+
stats["preflightWarnings"] = list(preflight_warnings)
|
| 279 |
+
|
| 280 |
+
_report(80, "Saving results")
|
| 281 |
+
|
| 282 |
+
base_name = f"{user.id}_{uuid.uuid4().hex}"
|
| 283 |
+
overlay_filename = base_name + ".png"
|
| 284 |
+
overlay_path = OVERLAYS_DIR / overlay_filename
|
| 285 |
+
overlay_path.parent.mkdir(parents=True, exist_ok=True)
|
| 286 |
+
Image.fromarray(result_image).save(overlay_path)
|
| 287 |
+
relative_overlay = f"overlays/{overlay_filename}"
|
| 288 |
+
|
| 289 |
+
relative_prob = ""
|
| 290 |
+
try:
|
| 291 |
+
from ..detection_config import get_save_prob_map
|
| 292 |
+
score_map = stats.pop("_score_map", None)
|
| 293 |
+
if get_save_prob_map() and score_map is not None:
|
| 294 |
+
import numpy as _np
|
| 295 |
+
prob_u8 = _np.clip(score_map * 255.0, 0, 255).astype("uint8")
|
| 296 |
+
prob_img = Image.fromarray(prob_u8)
|
| 297 |
+
prob_img.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
|
| 298 |
+
prob_file = OVERLAYS_DIR / f"{base_name}_prob.png"
|
| 299 |
+
prob_img.save(prob_file)
|
| 300 |
+
relative_prob = f"overlays/{base_name}_prob.png"
|
| 301 |
+
except Exception as exc:
|
| 302 |
+
logger.warning("Probability map export failed: %s", exc)
|
| 303 |
+
|
| 304 |
+
relative_before_full = ""
|
| 305 |
+
relative_before_thumb = ""
|
| 306 |
+
relative_after_thumb = ""
|
| 307 |
+
relative_after_full = ""
|
| 308 |
+
before_for_slider = None
|
| 309 |
+
try:
|
| 310 |
+
# Prefer the detection working grid (registered + same resolution as
|
| 311 |
+
# region polygons). Falling back to a fresh preprocess only if the
|
| 312 |
+
# engine did not provide slider frames (keeps older callers safe).
|
| 313 |
+
slider_before = stats.pop("_slider_before", None)
|
| 314 |
+
slider_after = stats.pop("_slider_after", None)
|
| 315 |
+
if slider_before is not None and slider_after is not None:
|
| 316 |
+
before_for_slider = Image.fromarray(slider_before)
|
| 317 |
+
after_for_slider = Image.fromarray(slider_after)
|
| 318 |
+
else:
|
| 319 |
+
from ..detection_engine import preprocess_image, get_detection_max_size
|
| 320 |
+
before_for_slider = Image.fromarray(
|
| 321 |
+
preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
|
| 322 |
+
)
|
| 323 |
+
after_for_slider = Image.fromarray(
|
| 324 |
+
preprocess_image(after_pil, max_size=max_size or get_detection_max_size())
|
| 325 |
+
)
|
| 326 |
+
before_full_file = OVERLAYS_DIR / f"{base_name}_before.png"
|
| 327 |
+
before_for_slider.save(before_full_file)
|
| 328 |
+
relative_before_full = f"overlays/{base_name}_before.png"
|
| 329 |
+
after_full_file = OVERLAYS_DIR / f"{base_name}_after.png"
|
| 330 |
+
after_for_slider.save(after_full_file)
|
| 331 |
+
relative_after_full = f"overlays/{base_name}_after.png"
|
| 332 |
+
before_thumb_pil = before_pil.copy()
|
| 333 |
+
before_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 334 |
+
before_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_before_thumb.png")
|
| 335 |
+
after_thumb_pil = after_pil.copy()
|
| 336 |
+
after_thumb_pil.thumbnail((THUMB_MAX_SIZE, THUMB_MAX_SIZE), Image.Resampling.LANCZOS)
|
| 337 |
+
after_thumb_pil.save(OVERLAYS_DIR / f"{base_name}_after_thumb.png")
|
| 338 |
+
relative_before_thumb = f"overlays/{base_name}_before_thumb.png"
|
| 339 |
+
relative_after_thumb = f"overlays/{base_name}_after_thumb.png"
|
| 340 |
+
except Exception as exc:
|
| 341 |
+
logger.warning("Failed to save thumbnails: %s", exc)
|
| 342 |
+
# Ensure det size fallback still has a slider image reference
|
| 343 |
+
from ..detection_engine import preprocess_image, get_detection_max_size
|
| 344 |
+
before_for_slider = Image.fromarray(
|
| 345 |
+
preprocess_image(before_pil, max_size=max_size or get_detection_max_size())
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
regions_serializable = _serialize_regions(change_regions)
|
| 349 |
+
det_w = int(stats.get("image_width") or 0)
|
| 350 |
+
det_h = int(stats.get("image_height") or 0)
|
| 351 |
+
if det_w <= 0 or det_h <= 0:
|
| 352 |
+
if before_for_slider is not None:
|
| 353 |
+
det_w, det_h = before_for_slider.size
|
| 354 |
+
else:
|
| 355 |
+
det_w, det_h = before_pil.size
|
| 356 |
+
|
| 357 |
+
geo_ctx = None
|
| 358 |
+
bounds = None
|
| 359 |
+
if geo_bounds_path:
|
| 360 |
+
rel_path = (base_path or "").replace("\\", "/").strip().lstrip("/")
|
| 361 |
+
if not rel_path:
|
| 362 |
+
try:
|
| 363 |
+
from .config import get_storage_root
|
| 364 |
+
rel_path = geo_bounds_path.resolve().relative_to(get_storage_root().resolve()).as_posix()
|
| 365 |
+
except Exception:
|
| 366 |
+
rel_path = geo_bounds_path.name
|
| 367 |
+
geo_ctx = resolve_geo_context(db, rel_path, geo_bounds_path)
|
| 368 |
+
bounds = geo_ctx.bounds
|
| 369 |
+
|
| 370 |
+
regions_serializable = enrich_regions_geo(
|
| 371 |
+
regions_serializable,
|
| 372 |
+
img_width=det_w,
|
| 373 |
+
img_height=det_h,
|
| 374 |
+
bounds=bounds,
|
| 375 |
+
geo=geo_ctx,
|
| 376 |
+
)
|
| 377 |
+
# Ops filter: PDF report (Grid_54 vs H43X2E1) showed many low-conf "Other"
|
| 378 |
+
# blobs at thr~0.2 β quarantine weak unclassified FPs without raising DL thr.
|
| 379 |
+
before_n = len(regions_serializable)
|
| 380 |
+
regions_serializable = _filter_weak_other_regions(regions_serializable)
|
| 381 |
+
dropped_other = before_n - len(regions_serializable)
|
| 382 |
+
if dropped_other:
|
| 383 |
+
logger.info("Filtered %d low-confidence Other regions", dropped_other)
|
| 384 |
+
regions_with_coords = sum(1 for r in regions_serializable if r.get("latLng"))
|
| 385 |
+
geo_debug = {
|
| 386 |
+
"source": geo_ctx.source if geo_ctx else "none",
|
| 387 |
+
"crs": str(geo_ctx.georef.crs) if (geo_ctx and geo_ctx.georef and geo_ctx.georef.crs) else "",
|
| 388 |
+
"bounds": list(bounds) if bounds else None,
|
| 389 |
+
"georefWidth": geo_ctx.georef_width if geo_ctx else 0,
|
| 390 |
+
"georefHeight": geo_ctx.georef_height if geo_ctx else 0,
|
| 391 |
+
"detectionWidth": det_w,
|
| 392 |
+
"detectionHeight": det_h,
|
| 393 |
+
"regionsWithCoords": regions_with_coords,
|
| 394 |
+
"regionsTotal": len(regions_serializable),
|
| 395 |
+
}
|
| 396 |
+
logger.info("Geo debug for run: %s", geo_debug)
|
| 397 |
+
total_px = int(stats["total_pixels"])
|
| 398 |
+
changed_px = int(stats["changed_pixels"])
|
| 399 |
+
change_pct = float(stats["change_percentage"])
|
| 400 |
+
|
| 401 |
+
run = DetectionRun(
|
| 402 |
+
user_id=user.id,
|
| 403 |
+
title=title,
|
| 404 |
+
method=method,
|
| 405 |
+
zone=zone,
|
| 406 |
+
village=village,
|
| 407 |
+
total_pixels=total_px,
|
| 408 |
+
changed_pixels=changed_px,
|
| 409 |
+
change_percentage=change_pct,
|
| 410 |
+
regions_count=len(regions_serializable),
|
| 411 |
+
overlay_path=relative_overlay,
|
| 412 |
+
before_full_path=relative_before_full,
|
| 413 |
+
before_thumb_path=relative_before_thumb,
|
| 414 |
+
after_thumb_path=relative_after_thumb,
|
| 415 |
+
after_full_path=relative_after_full,
|
| 416 |
+
regions_json=json.dumps(regions_serializable),
|
| 417 |
+
)
|
| 418 |
+
db.add(run)
|
| 419 |
+
db.commit()
|
| 420 |
+
db.refresh(run)
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
from .dda_auth import get_user_role
|
| 424 |
+
from .tree.audit_service import log_action
|
| 425 |
+
log_action(
|
| 426 |
+
db, "run_detection", action_by=getattr(user, "email", "") or "",
|
| 427 |
+
role=get_user_role(db, user), module="detect",
|
| 428 |
+
new_value={"run_id": run.id, "method": method, "zone": zone, "village": village,
|
| 429 |
+
"change_percentage": change_pct},
|
| 430 |
+
)
|
| 431 |
+
db.commit()
|
| 432 |
+
except Exception:
|
| 433 |
+
logger.exception("Failed to write audit log for detection run %s", run.id)
|
| 434 |
+
|
| 435 |
+
_report(90, "Preparing report")
|
| 436 |
+
overlay_b64 = base64.b64encode(overlay_path.read_bytes()).decode("utf-8")
|
| 437 |
+
notification_sent = False
|
| 438 |
+
notification_error = None
|
| 439 |
+
if notify_email and notify_email.strip():
|
| 440 |
+
_report(95, "Sending notification")
|
| 441 |
+
from .config import IS_DDA_MODE, get_public_base_url
|
| 442 |
+
report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
|
| 443 |
+
notification_sent, notification_error = send_notification(
|
| 444 |
+
recipient=notify_email.strip(),
|
| 445 |
+
title=title,
|
| 446 |
+
method=method,
|
| 447 |
+
zone=zone,
|
| 448 |
+
village=village,
|
| 449 |
+
change_pct=change_pct,
|
| 450 |
+
changed_px=changed_px,
|
| 451 |
+
total_px=total_px,
|
| 452 |
+
regions=regions_serializable,
|
| 453 |
+
report_url=report_url,
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
_report(100, "Complete")
|
| 457 |
+
|
| 458 |
+
return {
|
| 459 |
+
"id": run.id,
|
| 460 |
+
"title": run.title,
|
| 461 |
+
"method": run.method,
|
| 462 |
+
"zone": run.zone or "",
|
| 463 |
+
"village": run.village or "",
|
| 464 |
+
"basePath": base_path,
|
| 465 |
+
"comparisonPath": comparison_path,
|
| 466 |
+
"statistics": {
|
| 467 |
+
"totalPixels": total_px,
|
| 468 |
+
"changedPixels": changed_px,
|
| 469 |
+
"unchangedPixels": int(stats["unchanged_pixels"]),
|
| 470 |
+
"changePercentage": change_pct,
|
| 471 |
+
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 472 |
+
"params": stats.get("params", {}),
|
| 473 |
+
"alignmentWarning": stats.get("alignment_warning"),
|
| 474 |
+
"registrationOk": stats.get("params", {}).get("registration_ok"),
|
| 475 |
+
"preflightWarnings": stats.get("preflightWarnings") or [],
|
| 476 |
+
"geo": geo_debug,
|
| 477 |
+
"probabilityMapUrl": f"/api/overlay/{relative_prob}" if relative_prob else None,
|
| 478 |
+
},
|
| 479 |
+
"regions": regions_serializable,
|
| 480 |
+
"overlayBase64Png": overlay_b64,
|
| 481 |
+
"overlayUrl": f"/api/overlay/{relative_overlay}",
|
| 482 |
+
"beforeFullUrl": f"/api/overlay/{relative_before_full}" if relative_before_full else None,
|
| 483 |
+
"beforeThumbUrl": f"/api/overlay/{relative_before_thumb}" if relative_before_thumb else None,
|
| 484 |
+
"afterThumbUrl": f"/api/overlay/{relative_after_thumb}" if relative_after_thumb else None,
|
| 485 |
+
"afterFullUrl": f"/api/overlay/{relative_after_full}" if relative_after_full else None,
|
| 486 |
+
"notificationSent": notification_sent,
|
| 487 |
+
"notificationError": notification_error,
|
| 488 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 489 |
+
"detectionMaxSide": max_size,
|
| 490 |
+
}
|
app/dda/exception_routes.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API for the Exception Log page β system errors captured by the global
|
| 2 |
+
FastAPI exception handlers in app/main.py."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from ..database import get_db
|
| 12 |
+
from ..models import User
|
| 13 |
+
from .rbac.permissions import require_module_permission
|
| 14 |
+
from .tree.audit_service import isoformat_ist
|
| 15 |
+
from .tree.models import ExceptionLog
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _require_dda():
|
| 21 |
+
from .config import IS_DDA_MODE
|
| 22 |
+
if not IS_DDA_MODE:
|
| 23 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _entry_dict(entry: ExceptionLog, *, with_traceback: bool = False) -> dict:
|
| 27 |
+
d = {
|
| 28 |
+
"id": entry.id,
|
| 29 |
+
"timestamp": isoformat_ist(entry.created_at),
|
| 30 |
+
"type": entry.exception_type,
|
| 31 |
+
"message": entry.message or "",
|
| 32 |
+
"path": entry.path or "",
|
| 33 |
+
"method": entry.method or "",
|
| 34 |
+
"statusCode": entry.status_code,
|
| 35 |
+
"handled": bool(entry.handled),
|
| 36 |
+
"userEmail": entry.user_email or "",
|
| 37 |
+
}
|
| 38 |
+
if with_traceback:
|
| 39 |
+
d["traceback"] = entry.traceback or ""
|
| 40 |
+
return d
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _parse_date(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
|
| 44 |
+
if not value:
|
| 45 |
+
return None
|
| 46 |
+
try:
|
| 47 |
+
dt = datetime.fromisoformat(value)
|
| 48 |
+
except ValueError:
|
| 49 |
+
raise HTTPException(status_code=400, detail=f"Invalid date: {value!r} (expected YYYY-MM-DD)")
|
| 50 |
+
if end_of_day:
|
| 51 |
+
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
| 52 |
+
return dt
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/exception-log")
|
| 56 |
+
def list_exception_log(
|
| 57 |
+
exception_type: str = Query("", alias="type"),
|
| 58 |
+
status_code: Optional[int] = Query(None),
|
| 59 |
+
from_date: Optional[str] = Query(None, alias="from"),
|
| 60 |
+
to_date: Optional[str] = Query(None, alias="to"),
|
| 61 |
+
limit: int = Query(100, ge=1, le=1000),
|
| 62 |
+
offset: int = Query(0, ge=0),
|
| 63 |
+
user: User = Depends(require_module_permission("exception_log", "view")),
|
| 64 |
+
db: Session = Depends(get_db),
|
| 65 |
+
):
|
| 66 |
+
_require_dda()
|
| 67 |
+
q = db.query(ExceptionLog)
|
| 68 |
+
if exception_type:
|
| 69 |
+
q = q.filter(ExceptionLog.exception_type == exception_type)
|
| 70 |
+
if status_code is not None:
|
| 71 |
+
q = q.filter(ExceptionLog.status_code == status_code)
|
| 72 |
+
start = _parse_date(from_date)
|
| 73 |
+
if start:
|
| 74 |
+
q = q.filter(ExceptionLog.created_at >= start)
|
| 75 |
+
end = _parse_date(to_date, end_of_day=True)
|
| 76 |
+
if end:
|
| 77 |
+
q = q.filter(ExceptionLog.created_at <= end)
|
| 78 |
+
|
| 79 |
+
total = q.count()
|
| 80 |
+
page_rows = q.order_by(ExceptionLog.created_at.desc()).offset(offset).limit(limit).all()
|
| 81 |
+
|
| 82 |
+
# Stat cards summarize the *filtered* result set (matching the reference UI's
|
| 83 |
+
# "(this page)" labels), computed on the full filtered query, not just page_rows.
|
| 84 |
+
filtered = q
|
| 85 |
+
errors_500 = filtered.filter(ExceptionLog.status_code >= 500).count()
|
| 86 |
+
errors_other = total - errors_500
|
| 87 |
+
unhandled = filtered.filter(ExceptionLog.handled.is_(False)).count()
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"logs": [_entry_dict(r) for r in page_rows],
|
| 91 |
+
"total": total,
|
| 92 |
+
"limit": limit,
|
| 93 |
+
"offset": offset,
|
| 94 |
+
"stats": {"errors500": errors_500, "errorsOther": errors_other, "unhandled": unhandled},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/exception-log/filters")
|
| 99 |
+
def exception_log_filters(
|
| 100 |
+
user: User = Depends(require_module_permission("exception_log", "view")),
|
| 101 |
+
db: Session = Depends(get_db),
|
| 102 |
+
):
|
| 103 |
+
_require_dda()
|
| 104 |
+
types = [r[0] for r in db.query(ExceptionLog.exception_type).distinct().order_by(ExceptionLog.exception_type) if r[0]]
|
| 105 |
+
codes = sorted({r[0] for r in db.query(ExceptionLog.status_code).distinct() if r[0] is not None})
|
| 106 |
+
return {"types": types, "statusCodes": codes}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.get("/exception-log/{entry_id}")
|
| 110 |
+
def get_exception_log_entry(
|
| 111 |
+
entry_id: int,
|
| 112 |
+
user: User = Depends(require_module_permission("exception_log", "view")),
|
| 113 |
+
db: Session = Depends(get_db),
|
| 114 |
+
):
|
| 115 |
+
_require_dda()
|
| 116 |
+
entry = db.query(ExceptionLog).filter(ExceptionLog.id == entry_id).first()
|
| 117 |
+
if not entry:
|
| 118 |
+
raise HTTPException(status_code=404, detail="Exception log entry not found")
|
| 119 |
+
return _entry_dict(entry, with_traceback=True)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.delete("/exception-log/old")
|
| 123 |
+
def clear_old_exception_log(
|
| 124 |
+
days: int = Query(30, ge=1, le=3650),
|
| 125 |
+
user: User = Depends(require_module_permission("exception_log", "delete")),
|
| 126 |
+
db: Session = Depends(get_db),
|
| 127 |
+
):
|
| 128 |
+
_require_dda()
|
| 129 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
| 130 |
+
deleted = db.query(ExceptionLog).filter(ExceptionLog.created_at < cutoff).delete(synchronize_session=False)
|
| 131 |
+
db.commit()
|
| 132 |
+
return {"ok": True, "deleted": deleted}
|
app/dda/geo_regions.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pixel coordinates Γ’β β WGS84 lat/lng for geo-referenced images (FR-04, FR-06)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from .change_type_map import enrich_region_for_dda
|
| 14 |
+
from .geotiff_io import GeorefInfo, inspect_image, pixel_to_geo_wgs84, read_georef
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
BoundsWGS84 = Tuple[float, float, float, float] # west, south, east, north
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class GeoContext:
|
| 23 |
+
bounds: Optional[BoundsWGS84]
|
| 24 |
+
georef: Optional[GeorefInfo]
|
| 25 |
+
georef_width: int
|
| 26 |
+
georef_height: int
|
| 27 |
+
source: str = "none" # embedded | worldfile | manual | linear | none
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _bounds_in_range(b: Optional[BoundsWGS84]) -> bool:
|
| 31 |
+
if not b:
|
| 32 |
+
return False
|
| 33 |
+
w, s, e, n = b
|
| 34 |
+
return abs(w) <= 180 and abs(e) <= 180 and abs(s) <= 90 and abs(n) <= 90
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def parse_bounds(bounds: Any) -> Optional[BoundsWGS84]:
|
| 38 |
+
if bounds is None:
|
| 39 |
+
return None
|
| 40 |
+
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
|
| 41 |
+
return tuple(float(x) for x in bounds)
|
| 42 |
+
if isinstance(bounds, dict):
|
| 43 |
+
try:
|
| 44 |
+
return (
|
| 45 |
+
float(bounds["west"]),
|
| 46 |
+
float(bounds["south"]),
|
| 47 |
+
float(bounds["east"]),
|
| 48 |
+
float(bounds["north"]),
|
| 49 |
+
)
|
| 50 |
+
except (KeyError, TypeError, ValueError):
|
| 51 |
+
return None
|
| 52 |
+
if isinstance(bounds, str) and bounds.strip():
|
| 53 |
+
try:
|
| 54 |
+
data = json.loads(bounds)
|
| 55 |
+
return parse_bounds(data)
|
| 56 |
+
except json.JSONDecodeError:
|
| 57 |
+
parts = [float(x.strip()) for x in bounds.replace("[", "").replace("]", "").split(",")]
|
| 58 |
+
if len(parts) == 4:
|
| 59 |
+
return tuple(parts)
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def bounds_from_image_path(path: Path) -> Optional[BoundsWGS84]:
|
| 64 |
+
try:
|
| 65 |
+
meta = inspect_image(path)
|
| 66 |
+
return meta.bounds_wgs84
|
| 67 |
+
except Exception as exc:
|
| 68 |
+
logger.warning("Could not read bounds for %s: %s", path.name, exc)
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def resolve_geo_context(
|
| 73 |
+
db: Session,
|
| 74 |
+
base_path: str,
|
| 75 |
+
base_file: Path,
|
| 76 |
+
) -> GeoContext:
|
| 77 |
+
"""Resolve bounds and affine georef for detection geo enrichment.
|
| 78 |
+
|
| 79 |
+
Priority: embedded/world-file affine georef Γ’β β DB manual bounds Γ’β β linear
|
| 80 |
+
bounds inferred from the image sidecars. Tracks which source was used.
|
| 81 |
+
"""
|
| 82 |
+
georef = read_georef(base_file)
|
| 83 |
+
bounds = georef.bounds_wgs84 if georef else None
|
| 84 |
+
georef_width = georef.width if georef else 0
|
| 85 |
+
georef_height = georef.height if georef else 0
|
| 86 |
+
source = getattr(georef, "source", "embedded") if georef else "none"
|
| 87 |
+
|
| 88 |
+
if not bounds:
|
| 89 |
+
from .tree.image_service import get_image_by_file_path
|
| 90 |
+
|
| 91 |
+
rel = base_path.replace("\\", "/").strip().lstrip("/")
|
| 92 |
+
img = get_image_by_file_path(db, rel)
|
| 93 |
+
if img and img.bounds_json:
|
| 94 |
+
db_bounds = parse_bounds(img.bounds_json)
|
| 95 |
+
if db_bounds:
|
| 96 |
+
bounds = db_bounds
|
| 97 |
+
source = "manual"
|
| 98 |
+
|
| 99 |
+
if not bounds:
|
| 100 |
+
bounds = bounds_from_image_path(base_file)
|
| 101 |
+
if bounds:
|
| 102 |
+
source = "linear"
|
| 103 |
+
|
| 104 |
+
if (georef_width <= 0 or georef_height <= 0) and bounds:
|
| 105 |
+
meta = inspect_image(base_file)
|
| 106 |
+
georef_width = meta.width or georef_width
|
| 107 |
+
georef_height = meta.height or georef_height
|
| 108 |
+
|
| 109 |
+
return GeoContext(
|
| 110 |
+
bounds=bounds,
|
| 111 |
+
georef=georef,
|
| 112 |
+
georef_width=georef_width or 0,
|
| 113 |
+
georef_height=georef_height or 0,
|
| 114 |
+
source=source if bounds else "none",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def pixel_to_lat_lng(
|
| 119 |
+
x: float,
|
| 120 |
+
y: float,
|
| 121 |
+
img_width: int,
|
| 122 |
+
img_height: int,
|
| 123 |
+
bounds: BoundsWGS84,
|
| 124 |
+
*,
|
| 125 |
+
geo: Optional[GeoContext] = None,
|
| 126 |
+
) -> Optional[Dict[str, float]]:
|
| 127 |
+
if img_width <= 0 or img_height <= 0:
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
if geo and geo.georef:
|
| 131 |
+
coords = pixel_to_geo_wgs84(
|
| 132 |
+
x, y, geo.georef,
|
| 133 |
+
detection_width=img_width,
|
| 134 |
+
detection_height=img_height,
|
| 135 |
+
)
|
| 136 |
+
if coords:
|
| 137 |
+
lng, lat = coords
|
| 138 |
+
# Guard against bad transforms emitting impossible coordinates
|
| 139 |
+
if abs(lat) <= 90 and abs(lng) <= 180:
|
| 140 |
+
return {"lat": round(lat, 6), "lng": round(lng, 6)}
|
| 141 |
+
logger.warning("Affine produced out-of-range lat/lng (%.3f,%.3f); using linear", lat, lng)
|
| 142 |
+
|
| 143 |
+
if not bounds:
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
west, south, east, north = bounds
|
| 147 |
+
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
|
| 148 |
+
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
|
| 149 |
+
scale_x = ref_w / float(img_width)
|
| 150 |
+
scale_y = ref_h / float(img_height)
|
| 151 |
+
px = float(x) * scale_x
|
| 152 |
+
py = float(y) * scale_y
|
| 153 |
+
lng = west + (px / ref_w) * (east - west)
|
| 154 |
+
lat = north - (py / ref_h) * (north - south)
|
| 155 |
+
return {"lat": round(lat, 6), "lng": round(lng, 6)}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _ground_m_per_px(
|
| 159 |
+
img_width: int,
|
| 160 |
+
img_height: int,
|
| 161 |
+
bounds: BoundsWGS84,
|
| 162 |
+
*,
|
| 163 |
+
geo: Optional[GeoContext] = None,
|
| 164 |
+
) -> Optional[Tuple[float, float]]:
|
| 165 |
+
"""Metres per detection-image pixel along X (east) and Y (south)."""
|
| 166 |
+
if img_width <= 0 or img_height <= 0 or not bounds:
|
| 167 |
+
return None
|
| 168 |
+
west, south, east, north = bounds
|
| 169 |
+
ref_w = geo.georef_width if geo and geo.georef_width > 0 else img_width
|
| 170 |
+
ref_h = geo.georef_height if geo and geo.georef_height > 0 else img_height
|
| 171 |
+
if ref_w <= 0 or ref_h <= 0:
|
| 172 |
+
return None
|
| 173 |
+
scale_x = ref_w / float(img_width)
|
| 174 |
+
scale_y = ref_h / float(img_height)
|
| 175 |
+
mid_lat = (north + south) / 2.0
|
| 176 |
+
lat_scale = 111_320.0
|
| 177 |
+
lng_scale = 111_320.0 * math.cos(math.radians(mid_lat))
|
| 178 |
+
m_per_px_x = abs(east - west) / ref_w * scale_x * lng_scale
|
| 179 |
+
m_per_px_y = abs(north - south) / ref_h * scale_y * lat_scale
|
| 180 |
+
if m_per_px_x <= 0 or m_per_px_y <= 0:
|
| 181 |
+
return None
|
| 182 |
+
return m_per_px_x, m_per_px_y
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def bbox_area_sq_m(
|
| 186 |
+
bbox: Dict[str, int],
|
| 187 |
+
img_width: int,
|
| 188 |
+
img_height: int,
|
| 189 |
+
bounds: BoundsWGS84,
|
| 190 |
+
*,
|
| 191 |
+
geo: Optional[GeoContext] = None,
|
| 192 |
+
) -> Optional[float]:
|
| 193 |
+
"""Approximate region area in square metres using the axis-aligned bbox."""
|
| 194 |
+
scales = _ground_m_per_px(img_width, img_height, bounds, geo=geo)
|
| 195 |
+
if not scales:
|
| 196 |
+
return None
|
| 197 |
+
m_per_px_x, m_per_px_y = scales
|
| 198 |
+
w_m = float(bbox.get("w", 0)) * m_per_px_x
|
| 199 |
+
h_m = float(bbox.get("h", 0)) * m_per_px_y
|
| 200 |
+
return round(w_m * h_m, 1)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def polygon_area_px(polygon: Optional[List]) -> Optional[float]:
|
| 204 |
+
"""Shoelace area of an image-pixel ring (absolute value, closed or open)."""
|
| 205 |
+
if not polygon or len(polygon) < 3:
|
| 206 |
+
return None
|
| 207 |
+
try:
|
| 208 |
+
pts = [(float(p[0]), float(p[1])) for p in polygon]
|
| 209 |
+
except (TypeError, ValueError, IndexError):
|
| 210 |
+
return None
|
| 211 |
+
if pts[0] == pts[-1]:
|
| 212 |
+
pts = pts[:-1]
|
| 213 |
+
if len(pts) < 3:
|
| 214 |
+
return None
|
| 215 |
+
area2 = 0.0
|
| 216 |
+
n = len(pts)
|
| 217 |
+
for i in range(n):
|
| 218 |
+
x1, y1 = pts[i]
|
| 219 |
+
x2, y2 = pts[(i + 1) % n]
|
| 220 |
+
area2 += x1 * y2 - x2 * y1
|
| 221 |
+
return abs(area2) * 0.5
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def polygon_area_sq_m(
|
| 225 |
+
polygon: Optional[List],
|
| 226 |
+
img_width: int,
|
| 227 |
+
img_height: int,
|
| 228 |
+
bounds: Optional[BoundsWGS84],
|
| 229 |
+
*,
|
| 230 |
+
geo: Optional[GeoContext] = None,
|
| 231 |
+
) -> Optional[float]:
|
| 232 |
+
"""Ground area of a pixel-space polygon footprint (preferred over bbox)."""
|
| 233 |
+
if not bounds:
|
| 234 |
+
return None
|
| 235 |
+
scales = _ground_m_per_px(img_width, img_height, bounds, geo=geo)
|
| 236 |
+
if not scales:
|
| 237 |
+
return None
|
| 238 |
+
m_per_px_x, m_per_px_y = scales
|
| 239 |
+
px_area = polygon_area_px(polygon)
|
| 240 |
+
if px_area is None:
|
| 241 |
+
return None
|
| 242 |
+
return round(px_area * m_per_px_x * m_per_px_y, 1)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def polygon_to_lat_lng(
|
| 246 |
+
polygon: List[List[float]],
|
| 247 |
+
img_width: int,
|
| 248 |
+
img_height: int,
|
| 249 |
+
bounds: Optional[BoundsWGS84],
|
| 250 |
+
*,
|
| 251 |
+
geo: Optional[GeoContext] = None,
|
| 252 |
+
) -> Optional[List[Dict[str, float]]]:
|
| 253 |
+
"""Project a pixel-space polygon ring to WGS84, vertex by vertex.
|
| 254 |
+
|
| 255 |
+
Uses the same transform as the bbox-centre ``latLng`` so a polygon and its
|
| 256 |
+
region always agree. Returns None when any vertex fails to project, since a
|
| 257 |
+
partially-projected ring would draw a wrong footprint on a map.
|
| 258 |
+
"""
|
| 259 |
+
if not polygon:
|
| 260 |
+
return None
|
| 261 |
+
ring: List[Dict[str, float]] = []
|
| 262 |
+
for point in polygon:
|
| 263 |
+
try:
|
| 264 |
+
x, y = float(point[0]), float(point[1])
|
| 265 |
+
except (TypeError, ValueError, IndexError):
|
| 266 |
+
return None
|
| 267 |
+
coords = pixel_to_lat_lng(x, y, img_width, img_height, bounds, geo=geo)
|
| 268 |
+
if not coords:
|
| 269 |
+
return None
|
| 270 |
+
ring.append(coords)
|
| 271 |
+
return ring or None
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def enrich_regions_geo(
|
| 275 |
+
regions: List[dict],
|
| 276 |
+
*,
|
| 277 |
+
img_width: int,
|
| 278 |
+
img_height: int,
|
| 279 |
+
bounds: Optional[BoundsWGS84],
|
| 280 |
+
geo: Optional[GeoContext] = None,
|
| 281 |
+
) -> List[dict]:
|
| 282 |
+
"""Add latLng, polygonGeo, areaSqM, and Vedangsoft change type to each region."""
|
| 283 |
+
effective_bounds = bounds or (geo.bounds if geo else None)
|
| 284 |
+
out = []
|
| 285 |
+
for region in regions:
|
| 286 |
+
enriched = enrich_region_for_dda(region)
|
| 287 |
+
center = region.get("center") or {}
|
| 288 |
+
cx = center.get("x", 0)
|
| 289 |
+
cy = center.get("y", 0)
|
| 290 |
+
poly = region.get("polygon")
|
| 291 |
+
poly_px = polygon_area_px(poly)
|
| 292 |
+
if poly_px is not None:
|
| 293 |
+
enriched["polygonAreaPx"] = round(poly_px, 1)
|
| 294 |
+
if effective_bounds or (geo and geo.georef):
|
| 295 |
+
lat_lng = pixel_to_lat_lng(
|
| 296 |
+
cx, cy, img_width, img_height, effective_bounds,
|
| 297 |
+
geo=geo,
|
| 298 |
+
)
|
| 299 |
+
if lat_lng:
|
| 300 |
+
enriched["latLng"] = lat_lng
|
| 301 |
+
polygon_geo = polygon_to_lat_lng(
|
| 302 |
+
poly, img_width, img_height, effective_bounds,
|
| 303 |
+
geo=geo,
|
| 304 |
+
)
|
| 305 |
+
if polygon_geo:
|
| 306 |
+
enriched["polygonGeo"] = polygon_geo
|
| 307 |
+
bbox = region.get("bbox") or {}
|
| 308 |
+
if effective_bounds:
|
| 309 |
+
# Prefer true polygon footprint area; bbox is the fallback.
|
| 310 |
+
area_sq_m = polygon_area_sq_m(
|
| 311 |
+
poly, img_width, img_height, effective_bounds, geo=geo,
|
| 312 |
+
)
|
| 313 |
+
if area_sq_m is None:
|
| 314 |
+
area_sq_m = bbox_area_sq_m(
|
| 315 |
+
bbox, img_width, img_height, effective_bounds, geo=geo,
|
| 316 |
+
)
|
| 317 |
+
if area_sq_m is not None:
|
| 318 |
+
enriched["areaSqM"] = area_sq_m
|
| 319 |
+
else:
|
| 320 |
+
enriched["latLng"] = None
|
| 321 |
+
out.append(enriched)
|
| 322 |
+
return out
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def region_lat_lng(region: dict) -> tuple[Optional[float], Optional[float]]:
|
| 326 |
+
"""Read lat/lng from region dict (supports latLng object or flat keys)."""
|
| 327 |
+
ll = region.get("latLng") or {}
|
| 328 |
+
lat = region.get("latitude", ll.get("lat") if isinstance(ll, dict) else None)
|
| 329 |
+
lng = region.get("longitude", ll.get("lng") if isinstance(ll, dict) else None)
|
| 330 |
+
if lat is None or lng is None:
|
| 331 |
+
return None, None
|
| 332 |
+
try:
|
| 333 |
+
return float(lat), float(lng)
|
| 334 |
+
except (TypeError, ValueError):
|
| 335 |
+
return None, None
|
app/dda/geotiff_io.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GeoTIFF ingest and preview generation (FR-02). Rasterio optional at import time."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
from PIL import Image, ImageOps
|
| 11 |
+
|
| 12 |
+
from .config import get_detection_max_side
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
# World-file extensions per image type (ESRI convention + generic .wld)
|
| 17 |
+
_WORLD_FILE_EXTS = {
|
| 18 |
+
".tif": (".tfw", ".tifw", ".wld"),
|
| 19 |
+
".tiff": (".tfw", ".tifw", ".wld"),
|
| 20 |
+
".jpg": (".jgw", ".jpgw", ".wld"),
|
| 21 |
+
".jpeg": (".jgw", ".jpgw", ".wld"),
|
| 22 |
+
".png": (".pgw", ".pngw", ".wld"),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class IngestResult:
|
| 28 |
+
width: int
|
| 29 |
+
height: int
|
| 30 |
+
has_georef: bool
|
| 31 |
+
crs: str
|
| 32 |
+
bounds_wgs84: Optional[Tuple[float, float, float, float]] # west, south, east, north
|
| 33 |
+
format: str
|
| 34 |
+
georef_source: str = "none" # embedded | worldfile | none
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _find_world_file(path: Path) -> Optional[Path]:
|
| 38 |
+
"""Locate an ESRI world file or generic .wld sidecar next to an image."""
|
| 39 |
+
ext = path.suffix.lower()
|
| 40 |
+
candidates = []
|
| 41 |
+
for wext in _WORLD_FILE_EXTS.get(ext, (".wld",)):
|
| 42 |
+
candidates.append(path.with_suffix(wext))
|
| 43 |
+
# e.g. photo.jpg.wld
|
| 44 |
+
candidates.append(Path(str(path) + ".wld"))
|
| 45 |
+
for cand in candidates:
|
| 46 |
+
if cand.is_file():
|
| 47 |
+
return cand
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _read_prj_crs(path: Path):
|
| 52 |
+
"""Read a .prj sidecar (WKT) and return a rasterio CRS, or default EPSG:4326."""
|
| 53 |
+
try:
|
| 54 |
+
from rasterio.crs import CRS
|
| 55 |
+
except Exception:
|
| 56 |
+
return None
|
| 57 |
+
prj = path.with_suffix(".prj")
|
| 58 |
+
if prj.is_file():
|
| 59 |
+
try:
|
| 60 |
+
return CRS.from_wkt(prj.read_text(encoding="utf-8", errors="ignore").strip())
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
logger.warning("Could not parse .prj for %s: %s", path.name, exc)
|
| 63 |
+
try:
|
| 64 |
+
return CRS.from_epsg(4326)
|
| 65 |
+
except Exception:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _world_file_georef(path: Path, width: int, height: int):
|
| 70 |
+
"""Build (transform, crs, bounds_wgs84) from a world file sidecar, or None."""
|
| 71 |
+
wf = _find_world_file(path)
|
| 72 |
+
if wf is None:
|
| 73 |
+
return None
|
| 74 |
+
try:
|
| 75 |
+
from rasterio.transform import Affine, array_bounds
|
| 76 |
+
from rasterio.warp import transform_bounds
|
| 77 |
+
|
| 78 |
+
nums = [float(x.strip()) for x in wf.read_text().split() if x.strip()]
|
| 79 |
+
if len(nums) < 6:
|
| 80 |
+
return None
|
| 81 |
+
a, d, b, e, c, f = nums[:6]
|
| 82 |
+
# World file stores center of top-left pixel; shift to corner for GDAL/affine
|
| 83 |
+
gt_c = c - a / 2.0 - b / 2.0
|
| 84 |
+
gt_f = f - d / 2.0 - e / 2.0
|
| 85 |
+
transform = Affine(a, b, gt_c, d, e, gt_f)
|
| 86 |
+
crs = _read_prj_crs(path)
|
| 87 |
+
west, south, east, north = array_bounds(height, width, transform)
|
| 88 |
+
if crs is not None and str(crs) not in ("EPSG:4326", "OGC:CRS84"):
|
| 89 |
+
west, south, east, north = transform_bounds(crs, "EPSG:4326", west, south, east, north)
|
| 90 |
+
return transform, crs, (float(west), float(south), float(east), float(north))
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
logger.warning("World-file georef failed for %s: %s", path.name, exc)
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _read_with_rasterio(path: Path) -> IngestResult:
|
| 97 |
+
import rasterio
|
| 98 |
+
from rasterio.warp import transform_bounds
|
| 99 |
+
|
| 100 |
+
with rasterio.open(path) as src:
|
| 101 |
+
crs = str(src.crs) if src.crs else ""
|
| 102 |
+
has_georef = src.crs is not None
|
| 103 |
+
bounds = None
|
| 104 |
+
if has_georef and src.bounds:
|
| 105 |
+
try:
|
| 106 |
+
w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
|
| 107 |
+
bounds = (float(w), float(s), float(e), float(n))
|
| 108 |
+
except Exception as exc:
|
| 109 |
+
logger.warning("Could not transform bounds to WGS84: %s", exc)
|
| 110 |
+
ext = path.suffix.lower()
|
| 111 |
+
return IngestResult(
|
| 112 |
+
width=int(src.width),
|
| 113 |
+
height=int(src.height),
|
| 114 |
+
has_georef=has_georef,
|
| 115 |
+
crs=crs,
|
| 116 |
+
bounds_wgs84=bounds,
|
| 117 |
+
format="geotiff" if ext in (".tif", ".tiff") else "image",
|
| 118 |
+
georef_source="embedded" if has_georef else "none",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _read_with_pillow(path: Path) -> IngestResult:
|
| 123 |
+
with Image.open(path) as img:
|
| 124 |
+
w, h = img.size
|
| 125 |
+
ext = path.suffix.lower()
|
| 126 |
+
fmt = "geotiff" if ext in (".tif", ".tiff") else "image"
|
| 127 |
+
# Try a world-file sidecar for plain images / non-georeferenced TIFFs
|
| 128 |
+
wf = _world_file_georef(path, w, h)
|
| 129 |
+
if wf is not None:
|
| 130 |
+
_, crs, bounds = wf
|
| 131 |
+
return IngestResult(
|
| 132 |
+
width=w, height=h, has_georef=bounds is not None,
|
| 133 |
+
crs=str(crs) if crs else "", bounds_wgs84=bounds,
|
| 134 |
+
format=fmt, georef_source="worldfile" if bounds else "none",
|
| 135 |
+
)
|
| 136 |
+
return IngestResult(
|
| 137 |
+
width=w,
|
| 138 |
+
height=h,
|
| 139 |
+
has_georef=False,
|
| 140 |
+
crs="",
|
| 141 |
+
bounds_wgs84=None,
|
| 142 |
+
format=fmt,
|
| 143 |
+
georef_source="none",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def inspect_image(path: Path) -> IngestResult:
|
| 148 |
+
"""Read dimensions + georeferencing for any raster (TIFF/PNG/JPEG).
|
| 149 |
+
|
| 150 |
+
Tries rasterio first (honors embedded CRS, world files and GDAL .aux.xml for
|
| 151 |
+
all formats), then falls back to Pillow + explicit world-file parsing.
|
| 152 |
+
Corrupt / truncated GeoTIFFs must not crash library rescan β callers get a
|
| 153 |
+
zero-size stub and should skip indexing.
|
| 154 |
+
"""
|
| 155 |
+
try:
|
| 156 |
+
res = _read_with_rasterio(path)
|
| 157 |
+
# rasterio opened but found no embedded georef: try explicit world file
|
| 158 |
+
if not res.has_georef:
|
| 159 |
+
wf = _world_file_georef(path, res.width, res.height)
|
| 160 |
+
if wf is not None and wf[2] is not None:
|
| 161 |
+
_, crs, bounds = wf
|
| 162 |
+
res.has_georef = True
|
| 163 |
+
res.crs = str(crs) if crs else ""
|
| 164 |
+
res.bounds_wgs84 = bounds
|
| 165 |
+
res.georef_source = "worldfile"
|
| 166 |
+
return res
|
| 167 |
+
except ImportError:
|
| 168 |
+
logger.warning("rasterio not installed β georef metadata limited")
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
logger.warning("rasterio read failed (%s), falling back to Pillow", exc)
|
| 171 |
+
try:
|
| 172 |
+
return _read_with_pillow(path)
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
# Common for truncated / BigTIFF files Pillow cannot parse
|
| 175 |
+
# ("Invalid dimensions"). Don't abort the whole library sync.
|
| 176 |
+
logger.warning("Pillow inspect failed for %s: %s", path.name, exc)
|
| 177 |
+
ext = path.suffix.lower()
|
| 178 |
+
return IngestResult(
|
| 179 |
+
width=0,
|
| 180 |
+
height=0,
|
| 181 |
+
has_georef=False,
|
| 182 |
+
crs="",
|
| 183 |
+
bounds_wgs84=None,
|
| 184 |
+
format="geotiff" if ext in (".tif", ".tiff") else "image",
|
| 185 |
+
georef_source="none",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def write_placeholder_png(dest_path: Path, label: str = "Image", max_side: int = 256) -> None:
|
| 190 |
+
"""Fallback thumb when GeoTIFF is too large or rasterio is unavailable."""
|
| 191 |
+
from PIL import ImageDraw
|
| 192 |
+
|
| 193 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 194 |
+
img = Image.new("RGB", (max_side, max_side), color=(32, 40, 52))
|
| 195 |
+
draw = ImageDraw.Draw(img)
|
| 196 |
+
lines = [label[:28], "preview N/A"]
|
| 197 |
+
y = max_side // 2 - 20
|
| 198 |
+
for line in lines:
|
| 199 |
+
draw.text((12, y), line, fill=(100, 200, 170))
|
| 200 |
+
y += 18
|
| 201 |
+
img.save(dest_path, format="PNG")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _rasterio_read_rgb(path: Path, max_side: int):
|
| 205 |
+
import numpy as np
|
| 206 |
+
import rasterio
|
| 207 |
+
from rasterio.enums import Resampling
|
| 208 |
+
|
| 209 |
+
with rasterio.open(path) as src:
|
| 210 |
+
count = min(3, src.count)
|
| 211 |
+
scale = min(1.0, max_side / max(src.width, src.height, 1))
|
| 212 |
+
out_h = max(1, int(src.height * scale))
|
| 213 |
+
out_w = max(1, int(src.width * scale))
|
| 214 |
+
|
| 215 |
+
read_kwargs = {
|
| 216 |
+
"indexes": list(range(1, count + 1)),
|
| 217 |
+
"out_shape": (count, out_h, out_w),
|
| 218 |
+
"resampling": Resampling.bilinear,
|
| 219 |
+
}
|
| 220 |
+
try:
|
| 221 |
+
data = src.read(**read_kwargs)
|
| 222 |
+
except Exception:
|
| 223 |
+
# Fallback: read from coarsest overview when full decimation fails
|
| 224 |
+
if src.overviews(1):
|
| 225 |
+
with rasterio.open(path, overview_level=len(src.overviews(1))) as ov_src:
|
| 226 |
+
ocount = min(3, ov_src.count)
|
| 227 |
+
data = ov_src.read(
|
| 228 |
+
indexes=list(range(1, ocount + 1)),
|
| 229 |
+
out_shape=(ocount, out_h, out_w),
|
| 230 |
+
resampling=Resampling.bilinear,
|
| 231 |
+
)
|
| 232 |
+
else:
|
| 233 |
+
raise
|
| 234 |
+
|
| 235 |
+
if count == 1:
|
| 236 |
+
rgb = np.stack([data[0], data[0], data[0]])
|
| 237 |
+
else:
|
| 238 |
+
rgb = data[:3]
|
| 239 |
+
rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
|
| 240 |
+
if rgb.max() > 255 or rgb.min() < 0:
|
| 241 |
+
lo, hi = np.percentile(rgb, (2, 98))
|
| 242 |
+
rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
|
| 243 |
+
return Image.fromarray(rgb.astype("uint8"), mode="RGB")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def read_native_size(path: Path) -> Optional[Tuple[int, int]]:
|
| 247 |
+
"""Return (width, height) of a GeoTIFF without decoding pixels, or None."""
|
| 248 |
+
ext = path.suffix.lower()
|
| 249 |
+
if ext not in (".tif", ".tiff"):
|
| 250 |
+
return None
|
| 251 |
+
try:
|
| 252 |
+
import rasterio
|
| 253 |
+
with rasterio.open(path) as src:
|
| 254 |
+
return int(src.width), int(src.height)
|
| 255 |
+
except Exception:
|
| 256 |
+
return None
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _normalize_window_rgb(data):
|
| 260 |
+
"""Convert a rasterio (bands, h, w) window read to RGB uint8 (h, w, 3)."""
|
| 261 |
+
import numpy as np
|
| 262 |
+
count = data.shape[0]
|
| 263 |
+
if count == 1:
|
| 264 |
+
rgb = np.stack([data[0], data[0], data[0]])
|
| 265 |
+
else:
|
| 266 |
+
rgb = data[:3]
|
| 267 |
+
rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
|
| 268 |
+
if rgb.max() > 255 or rgb.min() < 0:
|
| 269 |
+
lo, hi = np.percentile(rgb, (2, 98))
|
| 270 |
+
rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
|
| 271 |
+
return rgb.astype("uint8")
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def iter_geotiff_window_pairs(path_a: Path, path_b: Path,
|
| 275 |
+
tile_size: int = 512, overlap: float = 0.25):
|
| 276 |
+
"""Stream paired native-resolution RGB windows from two GeoTIFFs.
|
| 277 |
+
|
| 278 |
+
Reads windows from disk via rasterio so very large rasters never load
|
| 279 |
+
fully into RAM. Yields ``(tile_a, tile_b, y0, x0, full_h, full_w)`` where
|
| 280 |
+
tiles are RGB uint8 on **path_a's** pixel grid.
|
| 281 |
+
|
| 282 |
+
Same-size pairs use direct window reads. Different pixel sizes (common for
|
| 283 |
+
same-bounds DDA pairs with slight GSD drift) reproject ``path_b`` into each
|
| 284 |
+
``path_a`` window via georef so windowed full-res inference still works.
|
| 285 |
+
"""
|
| 286 |
+
import numpy as np
|
| 287 |
+
import rasterio
|
| 288 |
+
from rasterio.enums import Resampling
|
| 289 |
+
from rasterio.warp import reproject
|
| 290 |
+
from rasterio.windows import Window, transform as window_transform
|
| 291 |
+
|
| 292 |
+
tile_size = max(64, int(tile_size))
|
| 293 |
+
ov = min(0.5, max(0.0, float(overlap)))
|
| 294 |
+
step = max(1, int(round(tile_size * (1.0 - ov))))
|
| 295 |
+
|
| 296 |
+
with rasterio.open(path_a) as src_a, rasterio.open(path_b) as src_b:
|
| 297 |
+
full_w, full_h = int(src_a.width), int(src_a.height)
|
| 298 |
+
same_grid = (src_a.width, src_a.height) == (src_b.width, src_b.height)
|
| 299 |
+
bands_a = list(range(1, min(3, src_a.count) + 1))
|
| 300 |
+
bands_b = list(range(1, min(3, src_b.count) + 1))
|
| 301 |
+
n_bands = len(bands_a)
|
| 302 |
+
|
| 303 |
+
ys = list(range(0, max(1, full_h - tile_size + 1), step))
|
| 304 |
+
xs = list(range(0, max(1, full_w - tile_size + 1), step))
|
| 305 |
+
if ys[-1] != full_h - tile_size and full_h > tile_size:
|
| 306 |
+
ys.append(full_h - tile_size)
|
| 307 |
+
if xs[-1] != full_w - tile_size and full_w > tile_size:
|
| 308 |
+
xs.append(full_w - tile_size)
|
| 309 |
+
|
| 310 |
+
for y0 in ys:
|
| 311 |
+
for x0 in xs:
|
| 312 |
+
wh = min(tile_size, full_h - y0)
|
| 313 |
+
ww = min(tile_size, full_w - x0)
|
| 314 |
+
win = Window(x0, y0, ww, wh)
|
| 315 |
+
da = src_a.read(indexes=bands_a, window=win)
|
| 316 |
+
|
| 317 |
+
if same_grid:
|
| 318 |
+
db = src_b.read(indexes=bands_b, window=win)
|
| 319 |
+
else:
|
| 320 |
+
# Warp after β before window CRS/transform/size
|
| 321 |
+
dst = np.zeros((n_bands, wh, ww), dtype=np.float32)
|
| 322 |
+
dst_transform = window_transform(win, src_a.transform)
|
| 323 |
+
for i, band in enumerate(bands_b[:n_bands]):
|
| 324 |
+
reproject(
|
| 325 |
+
source=rasterio.band(src_b, band),
|
| 326 |
+
destination=dst[i],
|
| 327 |
+
src_transform=src_b.transform,
|
| 328 |
+
src_crs=src_b.crs,
|
| 329 |
+
dst_transform=dst_transform,
|
| 330 |
+
dst_crs=src_a.crs,
|
| 331 |
+
resampling=Resampling.bilinear,
|
| 332 |
+
)
|
| 333 |
+
db = dst
|
| 334 |
+
|
| 335 |
+
yield (_normalize_window_rgb(da), _normalize_window_rgb(db),
|
| 336 |
+
y0, x0, full_h, full_w)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def load_rgb_pil(path: Path, max_side: Optional[int] = None) -> Image.Image:
|
| 340 |
+
"""Load image as RGB PIL, downscaling large GeoTIFFs via rasterio."""
|
| 341 |
+
if max_side is None:
|
| 342 |
+
max_side = get_detection_max_side()
|
| 343 |
+
ext = path.suffix.lower()
|
| 344 |
+
if ext in (".tif", ".tiff"):
|
| 345 |
+
try:
|
| 346 |
+
img = _rasterio_read_rgb(path, max_side)
|
| 347 |
+
return img.copy()
|
| 348 |
+
except ImportError as exc:
|
| 349 |
+
raise RuntimeError(
|
| 350 |
+
"GeoTIFF support requires rasterio. Install with: pip install rasterio"
|
| 351 |
+
) from exc
|
| 352 |
+
except Exception as exc:
|
| 353 |
+
raise RuntimeError(f"Could not read GeoTIFF: {exc}") from exc
|
| 354 |
+
with Image.open(path) as img:
|
| 355 |
+
img = ImageOps.exif_transpose(img)
|
| 356 |
+
img = img.convert("RGB")
|
| 357 |
+
if max(img.size) > max_side:
|
| 358 |
+
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 359 |
+
return img.copy()
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def parse_roi(roi) -> Optional[dict]:
|
| 363 |
+
"""Validate a fractional ROI ``{x, y, w, h}`` (all in [0, 1]).
|
| 364 |
+
|
| 365 |
+
Returns a normalized dict (w/h clamped so the box stays inside the image),
|
| 366 |
+
or ``None`` when ``roi`` is empty/falsey. Raises ``ValueError`` on a
|
| 367 |
+
malformed or out-of-bounds ROI so callers can turn it into an HTTP 400.
|
| 368 |
+
"""
|
| 369 |
+
if not roi:
|
| 370 |
+
return None
|
| 371 |
+
try:
|
| 372 |
+
x, y = float(roi["x"]), float(roi["y"])
|
| 373 |
+
w, h = float(roi["w"]), float(roi["h"])
|
| 374 |
+
except (KeyError, TypeError, ValueError):
|
| 375 |
+
raise ValueError("roi must have numeric x, y, w, h")
|
| 376 |
+
if not (0.0 <= x < 1.0 and 0.0 <= y < 1.0):
|
| 377 |
+
raise ValueError("roi x, y must be in [0, 1)")
|
| 378 |
+
if not (0.0 < w <= 1.0 and 0.0 < h <= 1.0):
|
| 379 |
+
raise ValueError("roi w, h must be in (0, 1]")
|
| 380 |
+
if x + w > 1.0 + 1e-6 or y + h > 1.0 + 1e-6:
|
| 381 |
+
raise ValueError("roi extends past image bounds (x+w or y+h > 1)")
|
| 382 |
+
return {"x": x, "y": y, "w": min(w, 1.0 - x), "h": min(h, 1.0 - y)}
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _rasterio_read_roi(path: Path, roi: dict, max_side: int) -> Image.Image:
|
| 386 |
+
"""Read only the ROI window of a GeoTIFF natively (fast, low memory)."""
|
| 387 |
+
import rasterio
|
| 388 |
+
from rasterio.enums import Resampling
|
| 389 |
+
from rasterio.windows import Window
|
| 390 |
+
|
| 391 |
+
with rasterio.open(path) as src:
|
| 392 |
+
W, H = int(src.width), int(src.height)
|
| 393 |
+
col0 = int(round(roi["x"] * W))
|
| 394 |
+
row0 = int(round(roi["y"] * H))
|
| 395 |
+
cw = min(max(1, int(round(roi["w"] * W))), W - col0)
|
| 396 |
+
ch = min(max(1, int(round(roi["h"] * H))), H - row0)
|
| 397 |
+
win = Window(col0, row0, cw, ch)
|
| 398 |
+
count = min(3, src.count)
|
| 399 |
+
scale = min(1.0, max_side / max(cw, ch, 1))
|
| 400 |
+
out_h = max(1, int(ch * scale))
|
| 401 |
+
out_w = max(1, int(cw * scale))
|
| 402 |
+
data = src.read(
|
| 403 |
+
indexes=list(range(1, count + 1)), window=win,
|
| 404 |
+
out_shape=(count, out_h, out_w), resampling=Resampling.bilinear,
|
| 405 |
+
)
|
| 406 |
+
return Image.fromarray(_normalize_window_rgb(data), mode="RGB")
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def load_rgb_roi(path: Path, roi, max_side: Optional[int] = None) -> Image.Image:
|
| 410 |
+
"""Load only a fractional ROI window of an image as RGB PIL.
|
| 411 |
+
|
| 412 |
+
``roi = {x, y, w, h}`` are fractions in [0, 1] of the image's own pixel
|
| 413 |
+
grid. For GeoTIFFs the window is read natively from disk (so a small ROI on
|
| 414 |
+
a huge raster stays fast and low-memory), then downscaled to ``max_side``;
|
| 415 |
+
other formats are opened, cropped, and downscaled. When ``roi`` is empty,
|
| 416 |
+
falls back to a full-image load.
|
| 417 |
+
"""
|
| 418 |
+
if max_side is None:
|
| 419 |
+
max_side = get_detection_max_side()
|
| 420 |
+
roi = parse_roi(roi)
|
| 421 |
+
if roi is None:
|
| 422 |
+
return load_rgb_pil(path, max_side=max_side)
|
| 423 |
+
ext = path.suffix.lower()
|
| 424 |
+
if ext in (".tif", ".tiff"):
|
| 425 |
+
try:
|
| 426 |
+
return _rasterio_read_roi(path, roi, max_side).copy()
|
| 427 |
+
except ImportError as exc:
|
| 428 |
+
raise RuntimeError(
|
| 429 |
+
"GeoTIFF support requires rasterio. Install with: pip install rasterio"
|
| 430 |
+
) from exc
|
| 431 |
+
except Exception as exc:
|
| 432 |
+
raise RuntimeError(f"Could not read GeoTIFF ROI: {exc}") from exc
|
| 433 |
+
with Image.open(path) as img:
|
| 434 |
+
img = ImageOps.exif_transpose(img).convert("RGB")
|
| 435 |
+
W, H = img.size
|
| 436 |
+
left = int(round(roi["x"] * W))
|
| 437 |
+
top = int(round(roi["y"] * H))
|
| 438 |
+
right = max(left + 1, int(round((roi["x"] + roi["w"]) * W)))
|
| 439 |
+
bottom = max(top + 1, int(round((roi["y"] + roi["h"]) * H)))
|
| 440 |
+
crop = img.crop((left, top, right, bottom))
|
| 441 |
+
if max(crop.size) > max_side:
|
| 442 |
+
crop.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 443 |
+
return crop.copy()
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def raster_to_preview_png(src_path: Path, dest_path: Path, max_side: int = 512) -> None:
|
| 447 |
+
"""Create RGB thumbnail/preview β uses decimated read for large GeoTIFFs."""
|
| 448 |
+
ext = src_path.suffix.lower()
|
| 449 |
+
if ext in (".tif", ".tiff"):
|
| 450 |
+
try:
|
| 451 |
+
img = _rasterio_read_rgb(src_path, max_side)
|
| 452 |
+
if max(img.size) > max_side:
|
| 453 |
+
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 454 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 455 |
+
img.save(dest_path, format="PNG")
|
| 456 |
+
return
|
| 457 |
+
except Exception as exc:
|
| 458 |
+
logger.warning("GeoTIFF preview failed for %s: %s", src_path.name, exc)
|
| 459 |
+
write_placeholder_png(dest_path, src_path.name, max_side)
|
| 460 |
+
return
|
| 461 |
+
|
| 462 |
+
try:
|
| 463 |
+
with Image.open(src_path) as img:
|
| 464 |
+
img = ImageOps.exif_transpose(img)
|
| 465 |
+
img = img.convert("RGB")
|
| 466 |
+
img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
| 467 |
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 468 |
+
img.save(dest_path, format="PNG")
|
| 469 |
+
except Exception as exc:
|
| 470 |
+
logger.warning("Preview failed for %s: %s", src_path.name, exc)
|
| 471 |
+
write_placeholder_png(dest_path, src_path.name, max_side)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
@dataclass
|
| 475 |
+
class GeorefInfo:
|
| 476 |
+
transform: Any
|
| 477 |
+
crs: Any
|
| 478 |
+
width: int
|
| 479 |
+
height: int
|
| 480 |
+
bounds_wgs84: Optional[Tuple[float, float, float, float]]
|
| 481 |
+
source: str = "embedded" # embedded | worldfile
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def read_georef(path: Path) -> Optional[GeorefInfo]:
|
| 485 |
+
"""Read raster affine transform + WGS84 bounds for any raster format.
|
| 486 |
+
|
| 487 |
+
Honors embedded CRS (GeoTIFF), GDAL .aux.xml sidecars, and ESRI world files
|
| 488 |
+
for TIFF/PNG/JPEG. Returns None when no georeferencing can be resolved.
|
| 489 |
+
"""
|
| 490 |
+
width = height = 0
|
| 491 |
+
try:
|
| 492 |
+
import rasterio
|
| 493 |
+
from rasterio.warp import transform_bounds
|
| 494 |
+
|
| 495 |
+
with rasterio.open(path) as src:
|
| 496 |
+
width, height = int(src.width), int(src.height)
|
| 497 |
+
if src.crs is not None:
|
| 498 |
+
bounds = None
|
| 499 |
+
try:
|
| 500 |
+
w, s, e, n = transform_bounds(src.crs, "EPSG:4326", *src.bounds)
|
| 501 |
+
bounds = (float(w), float(s), float(e), float(n))
|
| 502 |
+
except Exception as exc:
|
| 503 |
+
logger.warning("Could not transform bounds for %s: %s", path.name, exc)
|
| 504 |
+
return GeorefInfo(
|
| 505 |
+
transform=src.transform, crs=src.crs,
|
| 506 |
+
width=width, height=height, bounds_wgs84=bounds, source="embedded",
|
| 507 |
+
)
|
| 508 |
+
except ImportError:
|
| 509 |
+
return None
|
| 510 |
+
except Exception as exc:
|
| 511 |
+
logger.warning("read_georef rasterio open failed for %s: %s", path.name, exc)
|
| 512 |
+
|
| 513 |
+
# No embedded CRS β try an explicit world-file sidecar
|
| 514 |
+
if width <= 0 or height <= 0:
|
| 515 |
+
try:
|
| 516 |
+
with Image.open(path) as img:
|
| 517 |
+
width, height = img.size
|
| 518 |
+
except Exception:
|
| 519 |
+
return None
|
| 520 |
+
wf = _world_file_georef(path, width, height)
|
| 521 |
+
if wf is not None and wf[2] is not None:
|
| 522 |
+
transform, crs, bounds = wf
|
| 523 |
+
return GeorefInfo(
|
| 524 |
+
transform=transform, crs=crs,
|
| 525 |
+
width=width, height=height, bounds_wgs84=bounds, source="worldfile",
|
| 526 |
+
)
|
| 527 |
+
return None
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def read_gsd_meters(path: Path) -> Optional[float]:
|
| 531 |
+
"""Ground sample distance (meters/pixel) from the raster's georeferencing.
|
| 532 |
+
|
| 533 |
+
For geographic CRS (degrees) the pixel size is converted to meters at the
|
| 534 |
+
raster's center latitude. Returns None when no usable georef exists.
|
| 535 |
+
"""
|
| 536 |
+
info = read_georef(path)
|
| 537 |
+
if info is None or info.transform is None:
|
| 538 |
+
return None
|
| 539 |
+
try:
|
| 540 |
+
px_x = abs(float(info.transform.a))
|
| 541 |
+
px_y = abs(float(info.transform.e))
|
| 542 |
+
if px_x <= 0 or px_y <= 0:
|
| 543 |
+
return None
|
| 544 |
+
gsd = (px_x + px_y) / 2.0
|
| 545 |
+
crs = info.crs
|
| 546 |
+
is_geographic = False
|
| 547 |
+
try:
|
| 548 |
+
is_geographic = bool(crs is not None and crs.is_geographic)
|
| 549 |
+
except Exception:
|
| 550 |
+
is_geographic = bool(crs is not None and "4326" in str(crs))
|
| 551 |
+
if is_geographic:
|
| 552 |
+
import math
|
| 553 |
+
lat = 0.0
|
| 554 |
+
if info.bounds_wgs84:
|
| 555 |
+
lat = (info.bounds_wgs84[1] + info.bounds_wgs84[3]) / 2.0
|
| 556 |
+
meters_per_deg = 111320.0 * max(0.2, math.cos(math.radians(lat)))
|
| 557 |
+
gsd *= meters_per_deg
|
| 558 |
+
return float(gsd) if gsd > 0 else None
|
| 559 |
+
except Exception as exc:
|
| 560 |
+
logger.warning("read_gsd_meters failed for %s: %s", path.name, exc)
|
| 561 |
+
return None
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def harmonize_pair_gsd(
|
| 565 |
+
before_pil: Image.Image,
|
| 566 |
+
after_pil: Image.Image,
|
| 567 |
+
before_path: Path,
|
| 568 |
+
after_path: Path,
|
| 569 |
+
):
|
| 570 |
+
"""Resample a loaded image pair to a common (coarser) ground resolution.
|
| 571 |
+
|
| 572 |
+
SRCDNet motivation: when the two acquisitions have different GSDs, naive
|
| 573 |
+
pixel-grid alignment upsamples the coarser image and the interpolated
|
| 574 |
+
texture produces spurious "changes". Downsampling BOTH to the coarser GSD
|
| 575 |
+
(area-weighted) avoids hallucinated texture diffs.
|
| 576 |
+
|
| 577 |
+
Accounts for any load-time downscaling by scaling each file's native GSD
|
| 578 |
+
with its loaded/native width ratio. Returns ``(before, after, debug)``
|
| 579 |
+
where debug is None when harmonization was skipped or not needed.
|
| 580 |
+
"""
|
| 581 |
+
from ..detection_config import get_gsd_harmonize, get_gsd_tolerance
|
| 582 |
+
|
| 583 |
+
if not get_gsd_harmonize():
|
| 584 |
+
return before_pil, after_pil, None
|
| 585 |
+
try:
|
| 586 |
+
gsd_b = read_gsd_meters(before_path)
|
| 587 |
+
gsd_a = read_gsd_meters(after_path)
|
| 588 |
+
if not gsd_b or not gsd_a:
|
| 589 |
+
return before_pil, after_pil, None
|
| 590 |
+
|
| 591 |
+
native_b = read_native_size(before_path)
|
| 592 |
+
native_a = read_native_size(after_path)
|
| 593 |
+
if not native_b or not native_a:
|
| 594 |
+
return before_pil, after_pil, None
|
| 595 |
+
|
| 596 |
+
# Effective GSD of the in-memory image after load-time downscaling
|
| 597 |
+
eff_b = gsd_b * (native_b[0] / float(before_pil.size[0]))
|
| 598 |
+
eff_a = gsd_a * (native_a[0] / float(after_pil.size[0]))
|
| 599 |
+
|
| 600 |
+
rel_diff = abs(eff_b - eff_a) / max(eff_b, eff_a)
|
| 601 |
+
tolerance = get_gsd_tolerance()
|
| 602 |
+
debug = {
|
| 603 |
+
"gsdBefore": round(eff_b, 4),
|
| 604 |
+
"gsdAfter": round(eff_a, 4),
|
| 605 |
+
"relDiff": round(rel_diff, 4),
|
| 606 |
+
"tolerance": tolerance,
|
| 607 |
+
"harmonized": False,
|
| 608 |
+
}
|
| 609 |
+
if rel_diff <= tolerance:
|
| 610 |
+
return before_pil, after_pil, debug
|
| 611 |
+
|
| 612 |
+
target = max(eff_b, eff_a) # coarser resolution wins
|
| 613 |
+
out = []
|
| 614 |
+
for img, eff in ((before_pil, eff_b), (after_pil, eff_a)):
|
| 615 |
+
scale = eff / target
|
| 616 |
+
if scale < 0.999:
|
| 617 |
+
new_size = (max(64, round(img.size[0] * scale)),
|
| 618 |
+
max(64, round(img.size[1] * scale)))
|
| 619 |
+
# BOX = area-weighted downsample (no hallucinated texture)
|
| 620 |
+
img = img.resize(new_size, Image.Resampling.BOX)
|
| 621 |
+
out.append(img)
|
| 622 |
+
debug["harmonized"] = True
|
| 623 |
+
debug["targetGsd"] = round(target, 4)
|
| 624 |
+
debug["sizeBefore"] = list(out[0].size)
|
| 625 |
+
debug["sizeAfter"] = list(out[1].size)
|
| 626 |
+
logger.info("GSD harmonized to %.3f m/px (before %.3f, after %.3f)",
|
| 627 |
+
target, eff_b, eff_a)
|
| 628 |
+
return out[0], out[1], debug
|
| 629 |
+
except Exception as exc:
|
| 630 |
+
logger.warning("GSD harmonization failed: %s", exc)
|
| 631 |
+
return before_pil, after_pil, None
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def pixel_to_geo_wgs84(
|
| 635 |
+
x: float,
|
| 636 |
+
y: float,
|
| 637 |
+
georef: GeorefInfo,
|
| 638 |
+
*,
|
| 639 |
+
detection_width: int,
|
| 640 |
+
detection_height: int,
|
| 641 |
+
) -> Optional[Tuple[float, float]]:
|
| 642 |
+
"""Map detection pixel (x=col, y=row) to WGS84 (lng, lat)."""
|
| 643 |
+
if detection_width <= 0 or detection_height <= 0:
|
| 644 |
+
return None
|
| 645 |
+
try:
|
| 646 |
+
from rasterio.transform import xy as transform_xy
|
| 647 |
+
from rasterio.warp import transform as warp_transform
|
| 648 |
+
|
| 649 |
+
scale_x = georef.width / float(detection_width)
|
| 650 |
+
scale_y = georef.height / float(detection_height)
|
| 651 |
+
col = float(x) * scale_x
|
| 652 |
+
row = float(y) * scale_y
|
| 653 |
+
geo_x, geo_y = transform_xy(georef.transform, row, col, offset="center")
|
| 654 |
+
if georef.crs and str(georef.crs) != "EPSG:4326":
|
| 655 |
+
lngs, lats = warp_transform(georef.crs, "EPSG:4326", [geo_x], [geo_y])
|
| 656 |
+
return float(lngs[0]), float(lats[0])
|
| 657 |
+
return float(geo_x), float(geo_y)
|
| 658 |
+
except Exception as exc:
|
| 659 |
+
logger.warning("pixel_to_geo_wgs84 failed: %s", exc)
|
| 660 |
+
return None
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def bounds_to_json(bounds: Optional[Tuple[float, float, float, float]]) -> str:
|
| 664 |
+
if not bounds:
|
| 665 |
+
return ""
|
| 666 |
+
return json.dumps({"west": bounds[0], "south": bounds[1], "east": bounds[2], "north": bounds[3]})
|
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
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background detection job runner (FR-04 async pipeline)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import threading
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from ..auth import get_or_create_guest_user
|
| 15 |
+
from ..database import SessionLocal
|
| 16 |
+
from ..models import DetectionRun
|
| 17 |
+
from .detect_service import run_detection_and_save
|
| 18 |
+
from .geotiff_io import load_rgb_pil
|
| 19 |
+
from .job_progress import update_job_progress
|
| 20 |
+
from .local_routes import safe_resolve
|
| 21 |
+
from .models import DetectionJob
|
| 22 |
+
from .preflight import PreflightResult
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
_runner_lock = threading.Lock()
|
| 27 |
+
_active_job_id: Optional[int] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _utcnow():
|
| 31 |
+
return datetime.now(timezone.utc)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _load_pair(
|
| 35 |
+
db: Session, base_path: str, comparison_path: str, roi: Optional[dict] = None,
|
| 36 |
+
) -> tuple[Image.Image, Image.Image, Path, Optional[dict], PreflightResult]:
|
| 37 |
+
from ..detection_config import get_load_max_side
|
| 38 |
+
from .geotiff_io import load_rgb_roi
|
| 39 |
+
from .preflight import run_preflight_checks
|
| 40 |
+
base_file = safe_resolve(base_path)
|
| 41 |
+
comp_file = safe_resolve(comparison_path)
|
| 42 |
+
|
| 43 |
+
preflight = run_preflight_checks(db, base_file, comp_file, base_path, comparison_path, roi=roi)
|
| 44 |
+
if preflight.hard_fail:
|
| 45 |
+
raise ValueError(preflight.fail_reason)
|
| 46 |
+
|
| 47 |
+
max_side = get_load_max_side()
|
| 48 |
+
if roi:
|
| 49 |
+
# Crop both images to the same fractional window before detection so a
|
| 50 |
+
# small ROI on a huge raster runs fast and at full detail.
|
| 51 |
+
before_pil = load_rgb_roi(base_file, roi, max_side=max_side)
|
| 52 |
+
after_pil = load_rgb_roi(comp_file, roi, max_side=max_side)
|
| 53 |
+
else:
|
| 54 |
+
before_pil = load_rgb_pil(base_file, max_side=max_side)
|
| 55 |
+
after_pil = load_rgb_pil(comp_file, max_side=max_side)
|
| 56 |
+
|
| 57 |
+
gsd_debug = None
|
| 58 |
+
try:
|
| 59 |
+
from .geotiff_io import harmonize_pair_gsd
|
| 60 |
+
before_pil, after_pil, gsd_debug = harmonize_pair_gsd(
|
| 61 |
+
before_pil, after_pil, base_file, comp_file)
|
| 62 |
+
except Exception as exc:
|
| 63 |
+
logger.warning("GSD harmonization skipped: %s", exc)
|
| 64 |
+
|
| 65 |
+
if before_pil.size != after_pil.size:
|
| 66 |
+
after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
|
| 67 |
+
return before_pil, after_pil, base_file, gsd_debug, preflight
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _parse_params(job: DetectionJob) -> Dict[str, Any]:
|
| 71 |
+
try:
|
| 72 |
+
return json.loads(job.params_json or "{}")
|
| 73 |
+
except json.JSONDecodeError:
|
| 74 |
+
return {}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _run_job_sync(job_id: int) -> None:
|
| 78 |
+
global _active_job_id
|
| 79 |
+
db = SessionLocal()
|
| 80 |
+
try:
|
| 81 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 82 |
+
if not job or job.status not in ("queued", "running"):
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
job.status = "running"
|
| 86 |
+
job.started_at = _utcnow()
|
| 87 |
+
job.error_message = ""
|
| 88 |
+
params = _parse_params(job)
|
| 89 |
+
method = job.method or params.get("method", "AI-Based Deep Learning")
|
| 90 |
+
created_by = job.created_by
|
| 91 |
+
notify_email = job.notify_email or params.get("notify_email")
|
| 92 |
+
db.commit()
|
| 93 |
+
# Detach the job row so this session cannot overwrite progress_pct
|
| 94 |
+
# written by update_job_progress() on another connection, and so we
|
| 95 |
+
# do not hold a SQLite/InnoDB transaction across the long CPU work.
|
| 96 |
+
db.expunge_all()
|
| 97 |
+
update_job_progress(job_id, 5, "Starting job")
|
| 98 |
+
|
| 99 |
+
base_path = params.get("base_path", "")
|
| 100 |
+
comparison_path = params.get("comparison_path", "")
|
| 101 |
+
if not base_path or not comparison_path:
|
| 102 |
+
raise ValueError("Job missing base_path or comparison_path in params_json")
|
| 103 |
+
|
| 104 |
+
roi = params.get("roi")
|
| 105 |
+
update_job_progress(job_id, 8, "Cropping ROI" if roi else "Loading images")
|
| 106 |
+
before_pil, after_pil, base_file, gsd_debug, preflight = _load_pair(
|
| 107 |
+
db, base_path, comparison_path, roi=roi)
|
| 108 |
+
comp_file = safe_resolve(comparison_path)
|
| 109 |
+
if preflight.warnings:
|
| 110 |
+
params["preflightWarnings"] = preflight.warnings
|
| 111 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 112 |
+
if job:
|
| 113 |
+
job.params_json = json.dumps(params)
|
| 114 |
+
db.commit()
|
| 115 |
+
db.expunge_all()
|
| 116 |
+
if roi:
|
| 117 |
+
logger.info("Job %d ROI crop %s -> working size %s",
|
| 118 |
+
job_id, roi, before_pil.size)
|
| 119 |
+
update_job_progress(job_id, 12, "Images loaded")
|
| 120 |
+
title = params.get("title") or f"{Path(base_path).name} vs {Path(comparison_path).name}"
|
| 121 |
+
from ..detection_config import get_load_max_side
|
| 122 |
+
# Must use fullres load cap β get_detection_max_side() is 4096 and was
|
| 123 |
+
# silently downscaling overnight "max res" runs back to preview size.
|
| 124 |
+
detect_max = get_load_max_side(base_path, comparison_path)
|
| 125 |
+
logger.info(
|
| 126 |
+
"Job %d detection max_size=%d (loaded %s)",
|
| 127 |
+
job_id, detect_max, before_pil.size,
|
| 128 |
+
)
|
| 129 |
+
result = run_detection_and_save(
|
| 130 |
+
db,
|
| 131 |
+
before_pil,
|
| 132 |
+
after_pil,
|
| 133 |
+
method=method,
|
| 134 |
+
title=title,
|
| 135 |
+
zone=params.get("zone", ""),
|
| 136 |
+
village=params.get("village", ""),
|
| 137 |
+
enable_registration=bool(params.get("enable_registration", True)),
|
| 138 |
+
enable_normalization=bool(params.get("enable_normalization", True)),
|
| 139 |
+
detection_sensitivity=float(params.get("detection_sensitivity", 0.45)),
|
| 140 |
+
min_region_area=params.get("min_region_area"),
|
| 141 |
+
notify_email=notify_email,
|
| 142 |
+
max_size=detect_max,
|
| 143 |
+
geo_bounds_path=base_file,
|
| 144 |
+
comparison_file=comp_file,
|
| 145 |
+
base_path=base_path,
|
| 146 |
+
comparison_path=comparison_path,
|
| 147 |
+
user_id=created_by,
|
| 148 |
+
job_id=job_id,
|
| 149 |
+
gsd_debug=gsd_debug,
|
| 150 |
+
shape_mode=params.get("shape_mode", "polygon"),
|
| 151 |
+
preflight_warnings=preflight.warnings,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
update_job_progress(job_id, 100, "Complete")
|
| 155 |
+
|
| 156 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 157 |
+
if not job:
|
| 158 |
+
logger.error("Detection job %d vanished after completion", job_id)
|
| 159 |
+
return
|
| 160 |
+
job.status = "completed"
|
| 161 |
+
job.run_id = result["id"]
|
| 162 |
+
job.completed_at = _utcnow()
|
| 163 |
+
db.commit()
|
| 164 |
+
logger.info("Detection job %d completed β run %s", job_id, result["id"])
|
| 165 |
+
from .auto_detect import mark_schedule_from_job
|
| 166 |
+
mark_schedule_from_job(job_id, status="completed", run_id=result["id"])
|
| 167 |
+
except Exception as exc:
|
| 168 |
+
logger.exception("Detection job %d failed", job_id)
|
| 169 |
+
try:
|
| 170 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 171 |
+
if job:
|
| 172 |
+
job.status = "failed"
|
| 173 |
+
job.error_message = str(exc)[:2000]
|
| 174 |
+
job.completed_at = _utcnow()
|
| 175 |
+
db.commit()
|
| 176 |
+
from .auto_detect import mark_schedule_from_job
|
| 177 |
+
mark_schedule_from_job(job_id, status="failed", run_id=None, error=str(exc)[:2000])
|
| 178 |
+
except Exception:
|
| 179 |
+
db.rollback()
|
| 180 |
+
finally:
|
| 181 |
+
with _runner_lock:
|
| 182 |
+
if _active_job_id == job_id:
|
| 183 |
+
_active_job_id = None
|
| 184 |
+
db.close()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _job_worker(job_id: int) -> None:
|
| 188 |
+
global _active_job_id
|
| 189 |
+
with _runner_lock:
|
| 190 |
+
_active_job_id = job_id
|
| 191 |
+
try:
|
| 192 |
+
_run_job_sync(job_id)
|
| 193 |
+
finally:
|
| 194 |
+
with _runner_lock:
|
| 195 |
+
if _active_job_id == job_id:
|
| 196 |
+
_active_job_id = None
|
| 197 |
+
try:
|
| 198 |
+
start_next_queued_job()
|
| 199 |
+
except Exception:
|
| 200 |
+
logger.exception("Could not start next queued detection job")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def enqueue_detection_job(job_id: int) -> bool:
|
| 204 |
+
"""Start job in a background thread. Returns False if another job is running."""
|
| 205 |
+
global _active_job_id
|
| 206 |
+
with _runner_lock:
|
| 207 |
+
if _active_job_id is not None:
|
| 208 |
+
return False
|
| 209 |
+
_active_job_id = job_id
|
| 210 |
+
thread = threading.Thread(target=_job_worker, args=(job_id,), daemon=True, name=f"dda-job-{job_id}")
|
| 211 |
+
thread.start()
|
| 212 |
+
return True
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def is_job_runner_busy() -> bool:
|
| 216 |
+
with _runner_lock:
|
| 217 |
+
return _active_job_id is not None
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def start_next_queued_job() -> bool:
|
| 221 |
+
"""Start the oldest queued job if the runner is idle.
|
| 222 |
+
|
| 223 |
+
Auto-scheduled jobs stay queued until the operator has clicked Start.
|
| 224 |
+
"""
|
| 225 |
+
if is_job_runner_busy():
|
| 226 |
+
return False
|
| 227 |
+
from .auto_detect import is_auto_schedule_job, scheduler_is_armed
|
| 228 |
+
armed = scheduler_is_armed()
|
| 229 |
+
db = SessionLocal()
|
| 230 |
+
try:
|
| 231 |
+
pending = (
|
| 232 |
+
db.query(DetectionJob)
|
| 233 |
+
.filter(DetectionJob.status == "queued")
|
| 234 |
+
.order_by(DetectionJob.created_at.asc())
|
| 235 |
+
.all()
|
| 236 |
+
)
|
| 237 |
+
for job in pending:
|
| 238 |
+
if is_auto_schedule_job(job) and not armed:
|
| 239 |
+
continue
|
| 240 |
+
return enqueue_detection_job(job.id)
|
| 241 |
+
return False
|
| 242 |
+
finally:
|
| 243 |
+
db.close()
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def reconcile_stale_jobs(db: Session, max_running_hours: float = 3.0) -> int:
|
| 247 |
+
"""Mark orphaned / stuck running jobs failed; re-queue oldest queued job.
|
| 248 |
+
|
| 249 |
+
- Orphans: status=running but no in-process worker (server restart).
|
| 250 |
+
- Stuck: status=running longer than ``max_running_hours`` (crashed worker).
|
| 251 |
+
"""
|
| 252 |
+
from datetime import timedelta
|
| 253 |
+
|
| 254 |
+
fixed = 0
|
| 255 |
+
busy = is_job_runner_busy()
|
| 256 |
+
cutoff = _utcnow() - timedelta(hours=max_running_hours)
|
| 257 |
+
running = db.query(DetectionJob).filter(DetectionJob.status == "running").all()
|
| 258 |
+
for job in running:
|
| 259 |
+
started = job.started_at or job.created_at
|
| 260 |
+
# Normalize naive datetimes from SQLite
|
| 261 |
+
if started is not None and started.tzinfo is None:
|
| 262 |
+
started = started.replace(tzinfo=timezone.utc)
|
| 263 |
+
stale_by_age = started is not None and started < cutoff
|
| 264 |
+
orphaned = not busy
|
| 265 |
+
if not (orphaned or stale_by_age):
|
| 266 |
+
continue
|
| 267 |
+
job.status = "failed"
|
| 268 |
+
if stale_by_age and busy:
|
| 269 |
+
job.error_message = (
|
| 270 |
+
f"Job timed out after {max_running_hours:g}h with no completion. "
|
| 271 |
+
"Please run detection again."
|
| 272 |
+
)
|
| 273 |
+
else:
|
| 274 |
+
job.error_message = "Job interrupted (server restarted). Please run detection again."
|
| 275 |
+
job.completed_at = _utcnow()
|
| 276 |
+
fixed += 1
|
| 277 |
+
if fixed:
|
| 278 |
+
db.commit()
|
| 279 |
+
logger.info("Reconciled %d stale running job(s)", fixed)
|
| 280 |
+
|
| 281 |
+
if not is_job_runner_busy():
|
| 282 |
+
start_next_queued_job()
|
| 283 |
+
return fixed
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def create_local_folder_job(
|
| 287 |
+
db: Session,
|
| 288 |
+
*,
|
| 289 |
+
base_path: str,
|
| 290 |
+
comparison_path: str,
|
| 291 |
+
method: str = "AI-Based Deep Learning",
|
| 292 |
+
title: str = "",
|
| 293 |
+
zone: str = "",
|
| 294 |
+
village: str = "",
|
| 295 |
+
enable_registration: bool = True,
|
| 296 |
+
enable_normalization: bool = True,
|
| 297 |
+
detection_sensitivity: float = 0.45,
|
| 298 |
+
min_region_area: Optional[int] = 150,
|
| 299 |
+
notify_email: str = "",
|
| 300 |
+
created_by: Optional[int] = None,
|
| 301 |
+
roi: Optional[dict] = None,
|
| 302 |
+
shape_mode: str = "polygon",
|
| 303 |
+
extra_params: Optional[Dict[str, Any]] = None,
|
| 304 |
+
) -> DetectionJob:
|
| 305 |
+
user = get_or_create_guest_user(db)
|
| 306 |
+
params = {
|
| 307 |
+
"source": "local_folder",
|
| 308 |
+
"base_path": base_path.replace("\\", "/"),
|
| 309 |
+
"comparison_path": comparison_path.replace("\\", "/"),
|
| 310 |
+
"method": method,
|
| 311 |
+
"title": title,
|
| 312 |
+
"zone": zone,
|
| 313 |
+
"village": village,
|
| 314 |
+
"enable_registration": enable_registration,
|
| 315 |
+
"enable_normalization": enable_normalization,
|
| 316 |
+
"detection_sensitivity": detection_sensitivity,
|
| 317 |
+
"min_region_area": min_region_area,
|
| 318 |
+
}
|
| 319 |
+
if extra_params:
|
| 320 |
+
params.update(extra_params)
|
| 321 |
+
if roi:
|
| 322 |
+
params["roi"] = roi
|
| 323 |
+
params["shape_mode"] = shape_mode
|
| 324 |
+
job = DetectionJob(
|
| 325 |
+
status="queued",
|
| 326 |
+
base_image_id=None,
|
| 327 |
+
comparison_image_id=None,
|
| 328 |
+
method=method,
|
| 329 |
+
params_json=json.dumps(params),
|
| 330 |
+
notify_email=notify_email or "",
|
| 331 |
+
created_by=created_by or user.id,
|
| 332 |
+
)
|
| 333 |
+
db.add(job)
|
| 334 |
+
db.commit()
|
| 335 |
+
db.refresh(job)
|
| 336 |
+
return job
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def job_to_dict(job: DetectionJob, run: Optional[DetectionRun] = None) -> dict:
|
| 340 |
+
from .job_progress import get_job_progress
|
| 341 |
+
|
| 342 |
+
params = _parse_params(job)
|
| 343 |
+
progress_pct, progress_stage = get_job_progress(params, job.status)
|
| 344 |
+
out = {
|
| 345 |
+
"id": job.id,
|
| 346 |
+
"status": job.status,
|
| 347 |
+
"method": job.method,
|
| 348 |
+
"basePath": params.get("base_path", ""),
|
| 349 |
+
"comparisonPath": params.get("comparison_path", ""),
|
| 350 |
+
"title": params.get("title", ""),
|
| 351 |
+
"runId": job.run_id,
|
| 352 |
+
"errorMessage": job.error_message or "",
|
| 353 |
+
"notifyEmail": job.notify_email or "",
|
| 354 |
+
"preflightWarnings": params.get("preflightWarnings", []),
|
| 355 |
+
"progressPct": progress_pct,
|
| 356 |
+
"progressStage": progress_stage,
|
| 357 |
+
"createdAt": job.created_at.isoformat() if job.created_at else None,
|
| 358 |
+
"startedAt": job.started_at.isoformat() if job.started_at else None,
|
| 359 |
+
"completedAt": job.completed_at.isoformat() if job.completed_at else None,
|
| 360 |
+
"source": params.get("source") or "",
|
| 361 |
+
"autoScheduled": params.get("source") == "auto_schedule",
|
| 362 |
+
"zone": params.get("zone") or (getattr(run, "zone", None) if run else "") or "",
|
| 363 |
+
"village": params.get("village") or (getattr(run, "village", None) if run else "") or "",
|
| 364 |
+
}
|
| 365 |
+
if run:
|
| 366 |
+
out["report"] = {
|
| 367 |
+
"id": run.id,
|
| 368 |
+
"title": run.title,
|
| 369 |
+
"changePercentage": run.change_percentage,
|
| 370 |
+
"regionsCount": run.regions_count,
|
| 371 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 372 |
+
}
|
| 373 |
+
return out
|
app/dda/jobs_routes.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async detection job API (FR-04)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from ..database import get_db
|
| 13 |
+
from ..models import DetectionRun, User
|
| 14 |
+
from .dda_auth import current_dda_user
|
| 15 |
+
from .job_runner import (
|
| 16 |
+
create_local_folder_job,
|
| 17 |
+
enqueue_detection_job,
|
| 18 |
+
is_job_runner_busy,
|
| 19 |
+
job_to_dict,
|
| 20 |
+
reconcile_stale_jobs,
|
| 21 |
+
)
|
| 22 |
+
from .local_routes import safe_resolve
|
| 23 |
+
from .models import DetectionJob
|
| 24 |
+
from .rbac.permissions import require_module_permission
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
router = APIRouter()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _require_dda():
|
| 31 |
+
from .config import IS_DDA_MODE
|
| 32 |
+
if not IS_DDA_MODE:
|
| 33 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post("/jobs")
|
| 37 |
+
async def create_job(
|
| 38 |
+
base_path: str = Form(...),
|
| 39 |
+
comparison_path: str = Form(...),
|
| 40 |
+
method: str = Form("AI-Based Deep Learning"),
|
| 41 |
+
title: str = Form(""),
|
| 42 |
+
zone: str = Form(""),
|
| 43 |
+
village: str = Form(""),
|
| 44 |
+
enable_registration: bool = Form(True),
|
| 45 |
+
enable_normalization: bool = Form(True),
|
| 46 |
+
detection_sensitivity: float = Form(0.45),
|
| 47 |
+
min_region_area: Optional[int] = Form(150),
|
| 48 |
+
notify_email: Optional[str] = Form(None),
|
| 49 |
+
roi: Optional[str] = Form(None),
|
| 50 |
+
shape_mode: str = Form("polygon"),
|
| 51 |
+
db: Session = Depends(get_db),
|
| 52 |
+
user: User = Depends(require_module_permission("detect", "create")),
|
| 53 |
+
):
|
| 54 |
+
"""Queue async detection from local library paths. Returns immediately with jobId.
|
| 55 |
+
|
| 56 |
+
``roi`` (optional) is a JSON object ``{"x","y","w","h"}`` with fractional
|
| 57 |
+
coordinates in [0,1]; when present, detection runs only on that cropped
|
| 58 |
+
window (fast "test on a small area").
|
| 59 |
+
"""
|
| 60 |
+
_require_dda()
|
| 61 |
+
base_norm = base_path.replace("\\", "/").strip()
|
| 62 |
+
comp_norm = comparison_path.replace("\\", "/").strip()
|
| 63 |
+
if not base_norm or not comp_norm:
|
| 64 |
+
raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
|
| 65 |
+
if base_norm == comp_norm:
|
| 66 |
+
raise HTTPException(status_code=400, detail="Base and comparison images must be different")
|
| 67 |
+
|
| 68 |
+
roi_dict = None
|
| 69 |
+
if roi and roi.strip():
|
| 70 |
+
import json as _json
|
| 71 |
+
|
| 72 |
+
from .geotiff_io import parse_roi
|
| 73 |
+
try:
|
| 74 |
+
roi_dict = parse_roi(_json.loads(roi))
|
| 75 |
+
except (ValueError, _json.JSONDecodeError) as exc:
|
| 76 |
+
raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
safe_resolve(base_norm)
|
| 80 |
+
safe_resolve(comp_norm)
|
| 81 |
+
except HTTPException:
|
| 82 |
+
raise
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
|
| 85 |
+
|
| 86 |
+
if is_job_runner_busy():
|
| 87 |
+
raise HTTPException(
|
| 88 |
+
status_code=409,
|
| 89 |
+
detail="Another detection job is already running. Wait for it to finish, then try again.",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Clear DB orphans left after UI timeout / server restart so new jobs can start
|
| 93 |
+
reconcile_stale_jobs(db)
|
| 94 |
+
|
| 95 |
+
if is_job_runner_busy():
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=409,
|
| 98 |
+
detail="Another detection job is already running. Wait for it to finish, then try again.",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
if not title.strip():
|
| 102 |
+
from pathlib import Path
|
| 103 |
+
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 104 |
+
|
| 105 |
+
job = create_local_folder_job(
|
| 106 |
+
db,
|
| 107 |
+
base_path=base_norm,
|
| 108 |
+
comparison_path=comp_norm,
|
| 109 |
+
method=method,
|
| 110 |
+
title=title,
|
| 111 |
+
zone=zone,
|
| 112 |
+
village=village,
|
| 113 |
+
enable_registration=enable_registration,
|
| 114 |
+
enable_normalization=enable_normalization,
|
| 115 |
+
detection_sensitivity=detection_sensitivity,
|
| 116 |
+
min_region_area=min_region_area,
|
| 117 |
+
notify_email=notify_email or "",
|
| 118 |
+
created_by=user.id,
|
| 119 |
+
roi=roi_dict,
|
| 120 |
+
shape_mode=shape_mode,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if not enqueue_detection_job(job.id):
|
| 124 |
+
job.status = "failed"
|
| 125 |
+
job.error_message = "Could not start background worker"
|
| 126 |
+
db.commit()
|
| 127 |
+
raise HTTPException(status_code=503, detail="Job queue is busy")
|
| 128 |
+
|
| 129 |
+
return {"jobId": job.id, "status": "queued", "message": "Detection job queued. Poll GET /api/dda/jobs/{id} for status."}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.get("/jobs/{job_id}")
|
| 133 |
+
def get_job(job_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 134 |
+
_require_dda()
|
| 135 |
+
from .auto_detect import is_auto_schedule_job, is_auto_schedule_run
|
| 136 |
+
|
| 137 |
+
job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
|
| 138 |
+
if not job:
|
| 139 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 140 |
+
if job.created_by and job.created_by != user.id and not is_auto_schedule_job(job):
|
| 141 |
+
raise HTTPException(status_code=403, detail="Not allowed to view this job")
|
| 142 |
+
|
| 143 |
+
run = None
|
| 144 |
+
if job.run_id:
|
| 145 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first()
|
| 146 |
+
|
| 147 |
+
data = job_to_dict(job, run=run)
|
| 148 |
+
if job.status == "completed" and run:
|
| 149 |
+
try:
|
| 150 |
+
data["result"] = _run_detail(db, run, user.id)
|
| 151 |
+
if data.get("preflightWarnings"):
|
| 152 |
+
data["result"].setdefault("statistics", {})["preflightWarnings"] = data["preflightWarnings"]
|
| 153 |
+
except HTTPException:
|
| 154 |
+
raise
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
logger.warning("Could not load full run for job %s: %s", job_id, exc)
|
| 157 |
+
data["resultError"] = str(exc)[:500]
|
| 158 |
+
return data
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _run_detail(db: Session, run: DetectionRun, user_id: int) -> dict:
|
| 162 |
+
import base64
|
| 163 |
+
|
| 164 |
+
from ..database import DATA_DIR
|
| 165 |
+
from .auto_detect import is_auto_schedule_run
|
| 166 |
+
|
| 167 |
+
if run.user_id != user_id and not is_auto_schedule_run(db, run.id):
|
| 168 |
+
raise HTTPException(status_code=403, detail="Not allowed")
|
| 169 |
+
|
| 170 |
+
regions = json.loads(run.regions_json or "[]")
|
| 171 |
+
from .review_service import merge_reviews
|
| 172 |
+
regions = merge_reviews(db, run.id, regions)
|
| 173 |
+
overlay_b64 = ""
|
| 174 |
+
if run.overlay_path:
|
| 175 |
+
overlay_file = DATA_DIR / run.overlay_path
|
| 176 |
+
if overlay_file.exists():
|
| 177 |
+
overlay_b64 = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
|
| 178 |
+
|
| 179 |
+
from ..detection_config import get_load_max_side
|
| 180 |
+
from .detect_service import _isoformat_ist
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"id": run.id,
|
| 184 |
+
"title": run.title,
|
| 185 |
+
"method": run.method,
|
| 186 |
+
"zone": run.zone or "",
|
| 187 |
+
"village": run.village or "",
|
| 188 |
+
"statistics": {
|
| 189 |
+
"totalPixels": run.total_pixels,
|
| 190 |
+
"changedPixels": run.changed_pixels,
|
| 191 |
+
"unchangedPixels": run.total_pixels - run.changed_pixels,
|
| 192 |
+
"changePercentage": run.change_percentage,
|
| 193 |
+
},
|
| 194 |
+
"regions": regions,
|
| 195 |
+
"overlayBase64Png": overlay_b64,
|
| 196 |
+
"overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
|
| 197 |
+
"beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
|
| 198 |
+
"beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
|
| 199 |
+
"afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
|
| 200 |
+
"afterFullUrl": f"/api/overlay/{run.after_full_path}" if getattr(run, "after_full_path", None) else None,
|
| 201 |
+
"createdAt": _isoformat_ist(run.created_at),
|
| 202 |
+
"detectionMaxSide": get_load_max_side(),
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@router.get("/jobs")
|
| 207 |
+
def list_jobs(
|
| 208 |
+
status: Optional[str] = Query(None),
|
| 209 |
+
limit: int = Query(20, ge=1, le=100),
|
| 210 |
+
db: Session = Depends(get_db),
|
| 211 |
+
user: User = Depends(current_dda_user),
|
| 212 |
+
):
|
| 213 |
+
"""Recent detection jobs for in-app notifications / reports feed (FR-05 partial)."""
|
| 214 |
+
_require_dda()
|
| 215 |
+
from .job_runner import reconcile_stale_jobs
|
| 216 |
+
reconcile_stale_jobs(db)
|
| 217 |
+
from .auto_detect import is_auto_schedule_job
|
| 218 |
+
q = db.query(DetectionJob).filter(DetectionJob.created_by == user.id)
|
| 219 |
+
if status:
|
| 220 |
+
q = q.filter(DetectionJob.status == status)
|
| 221 |
+
jobs = q.order_by(DetectionJob.created_at.desc()).limit(limit).all()
|
| 222 |
+
seen = {job.id for job in jobs}
|
| 223 |
+
extra = (
|
| 224 |
+
db.query(DetectionJob)
|
| 225 |
+
.order_by(DetectionJob.created_at.desc())
|
| 226 |
+
.limit(80)
|
| 227 |
+
.all()
|
| 228 |
+
)
|
| 229 |
+
for job in extra:
|
| 230 |
+
if job.id in seen:
|
| 231 |
+
continue
|
| 232 |
+
if not is_auto_schedule_job(job):
|
| 233 |
+
continue
|
| 234 |
+
if status and job.status != status:
|
| 235 |
+
continue
|
| 236 |
+
jobs.append(job)
|
| 237 |
+
seen.add(job.id)
|
| 238 |
+
jobs.sort(key=lambda j: j.id, reverse=True)
|
| 239 |
+
jobs = jobs[:limit]
|
| 240 |
+
out = []
|
| 241 |
+
for job in jobs:
|
| 242 |
+
run = db.query(DetectionRun).filter(DetectionRun.id == job.run_id).first() if job.run_id else None
|
| 243 |
+
out.append(job_to_dict(job, run=run))
|
| 244 |
+
return {"jobs": out, "runnerBusy": is_job_runner_busy()}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@router.get("/auto-detect")
|
| 248 |
+
def auto_detect_status(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 249 |
+
"""Schedule for identified same-area pairs (Automatic mode)."""
|
| 250 |
+
_require_dda()
|
| 251 |
+
from .auto_detect import refresh_schedule_rows, schedule_status_payload
|
| 252 |
+
|
| 253 |
+
try:
|
| 254 |
+
refresh_schedule_rows()
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
logger.warning("Auto-detect schedule sync skipped: %s", exc)
|
| 257 |
+
return schedule_status_payload(db)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class AutoDetectStartBody(BaseModel):
|
| 261 |
+
intervalDays: int = Field(10, ge=1, le=365)
|
| 262 |
+
runAt: str = "02:00"
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@router.post("/auto-detect/start")
|
| 266 |
+
def auto_detect_start(
|
| 267 |
+
body: AutoDetectStartBody,
|
| 268 |
+
db: Session = Depends(get_db),
|
| 269 |
+
user: User = Depends(require_module_permission("detect", "create")),
|
| 270 |
+
):
|
| 271 |
+
"""Arm the automatic queue. Does not enqueue jobs immediately."""
|
| 272 |
+
_require_dda()
|
| 273 |
+
from .auto_detect import start_schedule
|
| 274 |
+
try:
|
| 275 |
+
return start_schedule(interval_days=body.intervalDays, run_at=body.runAt)
|
| 276 |
+
except ValueError as exc:
|
| 277 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@router.post("/auto-detect/stop")
|
| 281 |
+
def auto_detect_stop(
|
| 282 |
+
db: Session = Depends(get_db),
|
| 283 |
+
user: User = Depends(require_module_permission("detect", "create")),
|
| 284 |
+
):
|
| 285 |
+
"""Stop the automatic queue and cancel queued auto jobs."""
|
| 286 |
+
_require_dda()
|
| 287 |
+
from .auto_detect import stop_schedule
|
| 288 |
+
return stop_schedule()
|
app/dda/library_routes.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import shutil
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import date, datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from ..auth import get_or_create_guest_user
|
| 14 |
+
from ..database import get_db
|
| 15 |
+
from ..models import User
|
| 16 |
+
from .dda_auth import current_dda_user
|
| 17 |
+
from .config import (
|
| 18 |
+
ALLOWED_EXTENSIONS,
|
| 19 |
+
IS_DDA_MODE,
|
| 20 |
+
LIBRARY_DIR,
|
| 21 |
+
PREVIEWS_DIR,
|
| 22 |
+
THUMBS_DIR,
|
| 23 |
+
ensure_library_dirs,
|
| 24 |
+
geotiff_io_available,
|
| 25 |
+
max_upload_bytes_for_extension,
|
| 26 |
+
)
|
| 27 |
+
from .geotiff_io import bounds_to_json, inspect_image, raster_to_preview_png
|
| 28 |
+
from .models import DdaVillage, DdaZone, ImageAsset
|
| 29 |
+
from .upload_io import stream_upload_to_file
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
router = APIRouter()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _require_dda():
|
| 36 |
+
if not IS_DDA_MODE:
|
| 37 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _image_to_dict(asset: ImageAsset, zone_name: str = "", village_name: str = "") -> dict:
|
| 41 |
+
return {
|
| 42 |
+
"id": asset.id,
|
| 43 |
+
"uuid": asset.uuid,
|
| 44 |
+
"zoneId": asset.zone_id,
|
| 45 |
+
"zoneName": zone_name,
|
| 46 |
+
"villageId": asset.village_id,
|
| 47 |
+
"villageName": village_name,
|
| 48 |
+
"areaName": asset.area_name or "",
|
| 49 |
+
"year": asset.year,
|
| 50 |
+
"gridId": asset.grid_id or "",
|
| 51 |
+
"captureDate": asset.capture_date.isoformat() if asset.capture_date else None,
|
| 52 |
+
"source": asset.source,
|
| 53 |
+
"format": asset.format,
|
| 54 |
+
"originalFilename": asset.original_filename,
|
| 55 |
+
"hasGeoref": asset.has_georef,
|
| 56 |
+
"crs": asset.crs or "",
|
| 57 |
+
"bounds": json.loads(asset.bounds_json) if asset.bounds_json else None,
|
| 58 |
+
"width": asset.width,
|
| 59 |
+
"height": asset.height,
|
| 60 |
+
"fileSizeBytes": asset.file_size_bytes,
|
| 61 |
+
"thumbUrl": f"/api/dda/images/{asset.id}/thumb" if asset.thumb_path else None,
|
| 62 |
+
"createdAt": asset.created_at.isoformat() if asset.created_at else None,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/config")
|
| 67 |
+
def dda_config():
|
| 68 |
+
_require_dda()
|
| 69 |
+
from .config import MAX_GEOTIFF_BYTES, MAX_IMAGE_BYTES, get_library_roots, get_storage_root
|
| 70 |
+
max_gb = MAX_GEOTIFF_BYTES / (1024 ** 3)
|
| 71 |
+
return {
|
| 72 |
+
"mode": "dda",
|
| 73 |
+
"appMode": "dda",
|
| 74 |
+
"maxUploadMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
| 75 |
+
"maxUploadGb": round(max_gb, 2),
|
| 76 |
+
"maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
| 77 |
+
"maxGeotiffBytes": MAX_GEOTIFF_BYTES,
|
| 78 |
+
"maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
|
| 79 |
+
"maxImageBytes": MAX_IMAGE_BYTES,
|
| 80 |
+
"geotiffEnabled": geotiff_io_available(),
|
| 81 |
+
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 82 |
+
"hierarchyMode": "tree",
|
| 83 |
+
"librarySource": "tree_library",
|
| 84 |
+
"storageRoot": str(get_storage_root()),
|
| 85 |
+
"localLibraryPaths": [str(r) for r in get_library_roots()],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/hierarchy")
|
| 90 |
+
def get_hierarchy(db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 91 |
+
_require_dda()
|
| 92 |
+
from .tree.tree_service import build_tree
|
| 93 |
+
return {"tree": build_tree(db)}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.get("/images")
|
| 97 |
+
def list_images(
|
| 98 |
+
zone_id: Optional[int] = Query(None),
|
| 99 |
+
village_id: Optional[int] = Query(None),
|
| 100 |
+
year: Optional[int] = Query(None),
|
| 101 |
+
q: Optional[str] = Query(None),
|
| 102 |
+
db: Session = Depends(get_db),
|
| 103 |
+
user: User = Depends(current_dda_user),
|
| 104 |
+
):
|
| 105 |
+
_require_dda()
|
| 106 |
+
query = db.query(ImageAsset).order_by(ImageAsset.created_at.desc())
|
| 107 |
+
if zone_id:
|
| 108 |
+
query = query.filter(ImageAsset.zone_id == zone_id)
|
| 109 |
+
if village_id:
|
| 110 |
+
query = query.filter(ImageAsset.village_id == village_id)
|
| 111 |
+
if year:
|
| 112 |
+
query = query.filter(ImageAsset.year == year)
|
| 113 |
+
if q:
|
| 114 |
+
like = f"%{q.strip()}%"
|
| 115 |
+
query = query.filter(
|
| 116 |
+
(ImageAsset.area_name.ilike(like))
|
| 117 |
+
| (ImageAsset.original_filename.ilike(like))
|
| 118 |
+
| (ImageAsset.grid_id.ilike(like))
|
| 119 |
+
)
|
| 120 |
+
assets = query.limit(200).all()
|
| 121 |
+
zone_map = {z.id: z.name for z in db.query(DdaZone).all()}
|
| 122 |
+
village_map = {v.id: v.name for v in db.query(DdaVillage).all()}
|
| 123 |
+
return [
|
| 124 |
+
_image_to_dict(
|
| 125 |
+
a,
|
| 126 |
+
zone_name=zone_map.get(a.zone_id, ""),
|
| 127 |
+
village_name=village_map.get(a.village_id, ""),
|
| 128 |
+
)
|
| 129 |
+
for a in assets
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.get("/images/{image_id}")
|
| 134 |
+
def get_image(image_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 135 |
+
_require_dda()
|
| 136 |
+
asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
|
| 137 |
+
if not asset:
|
| 138 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 139 |
+
zone_name = ""
|
| 140 |
+
village_name = ""
|
| 141 |
+
if asset.zone_id:
|
| 142 |
+
z = db.query(DdaZone).filter(DdaZone.id == asset.zone_id).first()
|
| 143 |
+
zone_name = z.name if z else ""
|
| 144 |
+
if asset.village_id:
|
| 145 |
+
v = db.query(DdaVillage).filter(DdaVillage.id == asset.village_id).first()
|
| 146 |
+
village_name = v.name if v else ""
|
| 147 |
+
return _image_to_dict(asset, zone_name=zone_name, village_name=village_name)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.get("/images/{image_id}/thumb")
|
| 151 |
+
def get_image_thumb(image_id: int, db: Session = Depends(get_db), user: User = Depends(current_dda_user)):
|
| 152 |
+
_require_dda()
|
| 153 |
+
asset = db.query(ImageAsset).filter(ImageAsset.id == image_id).first()
|
| 154 |
+
if not asset or not asset.thumb_path:
|
| 155 |
+
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
| 156 |
+
path = LIBRARY_DIR.parent / asset.thumb_path
|
| 157 |
+
if not path.exists():
|
| 158 |
+
raise HTTPException(status_code=404, detail="Thumbnail file missing")
|
| 159 |
+
return FileResponse(path, media_type="image/png")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.post("/images/upload")
|
| 163 |
+
async def upload_image(
|
| 164 |
+
file: UploadFile = File(...),
|
| 165 |
+
zone_id: int = Form(...),
|
| 166 |
+
village_id: int = Form(...),
|
| 167 |
+
area_name: str = Form(""),
|
| 168 |
+
year: int = Form(...),
|
| 169 |
+
capture_date: str = Form(...),
|
| 170 |
+
source: str = Form("satellite"),
|
| 171 |
+
grid_id: str = Form(""),
|
| 172 |
+
manual_bounds_json: str = Form(""),
|
| 173 |
+
db: Session = Depends(get_db),
|
| 174 |
+
):
|
| 175 |
+
_require_dda()
|
| 176 |
+
raise HTTPException(
|
| 177 |
+
status_code=410,
|
| 178 |
+
detail="Deprecated: use POST /api/dda/tree/nodes/{node_id}/images/upload",
|
| 179 |
+
)
|
| 180 |
+
ensure_library_dirs()
|
| 181 |
+
user = get_or_create_guest_user(db)
|
| 182 |
+
|
| 183 |
+
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 184 |
+
village = db.query(DdaVillage).filter(DdaVillage.id == village_id, DdaVillage.zone_id == zone_id).first()
|
| 185 |
+
if not zone or not village:
|
| 186 |
+
raise HTTPException(status_code=400, detail="Invalid zone or village")
|
| 187 |
+
|
| 188 |
+
original = file.filename or "upload"
|
| 189 |
+
ext = Path(original).suffix.lower()
|
| 190 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=400,
|
| 193 |
+
detail=f"Unsupported format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
cap_date = date.fromisoformat(capture_date.strip())
|
| 198 |
+
except ValueError:
|
| 199 |
+
raise HTTPException(status_code=400, detail="capture_date must be YYYY-MM-DD")
|
| 200 |
+
|
| 201 |
+
if source not in ("satellite", "drone"):
|
| 202 |
+
raise HTTPException(status_code=400, detail="source must be satellite or drone")
|
| 203 |
+
|
| 204 |
+
asset_uuid = uuid.uuid4().hex
|
| 205 |
+
stored_name = f"{asset_uuid}{ext}"
|
| 206 |
+
dest_file = LIBRARY_DIR / str(year) / stored_name
|
| 207 |
+
max_bytes = max_upload_bytes_for_extension(ext)
|
| 208 |
+
file_size = await stream_upload_to_file(file, dest_file, max_bytes)
|
| 209 |
+
|
| 210 |
+
ingest = inspect_image(dest_file)
|
| 211 |
+
has_georef = ingest.has_georef
|
| 212 |
+
manual_location = manual_bounds_json.strip()
|
| 213 |
+
|
| 214 |
+
if ext in (".tif", ".tiff") and not has_georef and not manual_location:
|
| 215 |
+
dest_file.unlink(missing_ok=True)
|
| 216 |
+
raise HTTPException(
|
| 217 |
+
status_code=400,
|
| 218 |
+
detail="GeoTIFF has no embedded georeferencing. Provide manual_bounds_json (west,south,east,north in WGS84).",
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
thumb_file = THUMBS_DIR / f"{asset_uuid}.png"
|
| 222 |
+
preview_file = PREVIEWS_DIR / f"{asset_uuid}.png"
|
| 223 |
+
try:
|
| 224 |
+
raster_to_preview_png(dest_file, thumb_file, max_side=256)
|
| 225 |
+
raster_to_preview_png(dest_file, preview_file, max_side=1024)
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
dest_file.unlink(missing_ok=True)
|
| 228 |
+
raise HTTPException(status_code=400, detail=f"Could not generate preview: {exc}")
|
| 229 |
+
|
| 230 |
+
rel_file = str(dest_file.relative_to(LIBRARY_DIR.parent))
|
| 231 |
+
rel_thumb = str(thumb_file.relative_to(LIBRARY_DIR.parent))
|
| 232 |
+
rel_preview = str(preview_file.relative_to(LIBRARY_DIR.parent))
|
| 233 |
+
|
| 234 |
+
bounds_json = bounds_to_json(ingest.bounds_wgs84)
|
| 235 |
+
if not bounds_json and manual_location:
|
| 236 |
+
try:
|
| 237 |
+
parts = [float(x.strip()) for x in manual_location.replace("[", "").replace("]", "").split(",")]
|
| 238 |
+
if len(parts) == 4:
|
| 239 |
+
bounds_json = bounds_to_json(tuple(parts))
|
| 240 |
+
except (ValueError, TypeError):
|
| 241 |
+
pass
|
| 242 |
+
|
| 243 |
+
asset = ImageAsset(
|
| 244 |
+
uuid=asset_uuid,
|
| 245 |
+
zone_id=zone_id,
|
| 246 |
+
village_id=village_id,
|
| 247 |
+
area_name=area_name.strip(),
|
| 248 |
+
year=year,
|
| 249 |
+
grid_id=grid_id.strip(),
|
| 250 |
+
capture_date=cap_date,
|
| 251 |
+
source=source,
|
| 252 |
+
format=ingest.format,
|
| 253 |
+
original_filename=original,
|
| 254 |
+
file_path=rel_file,
|
| 255 |
+
thumb_path=rel_thumb,
|
| 256 |
+
preview_path=rel_preview,
|
| 257 |
+
crs=ingest.crs,
|
| 258 |
+
bounds_json=bounds_json,
|
| 259 |
+
has_georef=has_georef or bool(bounds_json),
|
| 260 |
+
manual_location_json=manual_location,
|
| 261 |
+
width=ingest.width,
|
| 262 |
+
height=ingest.height,
|
| 263 |
+
file_size_bytes=file_size,
|
| 264 |
+
uploaded_by=user.id,
|
| 265 |
+
)
|
| 266 |
+
db.add(asset)
|
| 267 |
+
db.commit()
|
| 268 |
+
db.refresh(asset)
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
"status": "success",
|
| 272 |
+
"image": _image_to_dict(asset, zone_name=zone.name, village_name=village.name),
|
| 273 |
+
}
|
app/dda/local_library.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Read satellite/drone images from local year-based folders.
|
| 3 |
+
|
| 4 |
+
Folder layout (under library_sources/):
|
| 5 |
+
|
| 6 |
+
library_sources/
|
| 7 |
+
2024/
|
| 8 |
+
image_a.tif
|
| 9 |
+
2025/
|
| 10 |
+
image_c.tif
|
| 11 |
+
|
| 12 |
+
Copy files into library_sources/YEAR/ in the project folder (or data/library_sources/ on HF).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import hashlib
|
| 17 |
+
import logging
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import List, Optional
|
| 21 |
+
from urllib.parse import quote
|
| 22 |
+
|
| 23 |
+
from fastapi import HTTPException
|
| 24 |
+
|
| 25 |
+
from .config import ALLOWED_EXTENSIONS, LOCAL_THUMB_CACHE, get_library_roots
|
| 26 |
+
from .geotiff_io import inspect_image, raster_to_preview_png, write_placeholder_png
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class LocalImageEntry:
|
| 33 |
+
path: str # relative posix path, e.g. 2025/aerial.tif
|
| 34 |
+
root: Path
|
| 35 |
+
year: int
|
| 36 |
+
filename: str
|
| 37 |
+
file_size_bytes: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _is_year_dir(name: str) -> bool:
|
| 41 |
+
return len(name) == 4 and name.isdigit() and 1990 <= int(name) <= 2100
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _iter_image_files(root: Path, year: Optional[int] = None):
|
| 45 |
+
if not root.exists():
|
| 46 |
+
return
|
| 47 |
+
if year is not None:
|
| 48 |
+
year_dirs = [root / str(year)] if (root / str(year)).is_dir() else []
|
| 49 |
+
else:
|
| 50 |
+
year_dirs = [d for d in sorted(root.iterdir()) if d.is_dir() and _is_year_dir(d.name)]
|
| 51 |
+
for ydir in year_dirs:
|
| 52 |
+
y = int(ydir.name)
|
| 53 |
+
for path in sorted(ydir.rglob("*")):
|
| 54 |
+
if not path.is_file():
|
| 55 |
+
continue
|
| 56 |
+
ext = path.suffix.lower()
|
| 57 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 58 |
+
logger.debug("Skipped (extension %s): %s", ext, path)
|
| 59 |
+
continue
|
| 60 |
+
yield root, y, path
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def safe_resolve(relative_path: str) -> Path:
|
| 64 |
+
"""Resolve a library-relative path across all configured roots."""
|
| 65 |
+
rel = relative_path.replace("\\", "/").lstrip("/")
|
| 66 |
+
if not rel or ".." in rel.split("/"):
|
| 67 |
+
raise HTTPException(status_code=400, detail="Invalid image path")
|
| 68 |
+
for root in get_library_roots():
|
| 69 |
+
full = (root / rel).resolve()
|
| 70 |
+
try:
|
| 71 |
+
full.relative_to(root.resolve())
|
| 72 |
+
except ValueError:
|
| 73 |
+
continue
|
| 74 |
+
if full.is_file() and full.suffix.lower() in ALLOWED_EXTENSIONS:
|
| 75 |
+
return full
|
| 76 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def scan_years() -> List[dict]:
|
| 80 |
+
"""List year folders and image counts (merged across all roots)."""
|
| 81 |
+
ensure_root()
|
| 82 |
+
counts: dict[int, int] = {}
|
| 83 |
+
for root in get_library_roots():
|
| 84 |
+
if not root.exists():
|
| 85 |
+
continue
|
| 86 |
+
for entry in sorted(root.iterdir()):
|
| 87 |
+
if not entry.is_dir() or not _is_year_dir(entry.name):
|
| 88 |
+
continue
|
| 89 |
+
y = int(entry.name)
|
| 90 |
+
n = sum(1 for _ in _iter_image_files(root, year=y))
|
| 91 |
+
counts[y] = counts.get(y, 0) + n
|
| 92 |
+
return [{"year": y, "imageCount": counts[y]} for y in sorted(counts)]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def scan_images(year: Optional[int] = None, query: Optional[str] = None) -> List[LocalImageEntry]:
|
| 96 |
+
"""Scan all library roots for images."""
|
| 97 |
+
ensure_root()
|
| 98 |
+
results: List[LocalImageEntry] = []
|
| 99 |
+
seen_paths: set[str] = set()
|
| 100 |
+
q = (query or "").strip().lower()
|
| 101 |
+
|
| 102 |
+
for root in get_library_roots():
|
| 103 |
+
for r, y, path in _iter_image_files(root, year=year):
|
| 104 |
+
rel = path.relative_to(r).as_posix()
|
| 105 |
+
if rel in seen_paths:
|
| 106 |
+
continue
|
| 107 |
+
if q and q not in rel.lower():
|
| 108 |
+
continue
|
| 109 |
+
try:
|
| 110 |
+
size = path.stat().st_size
|
| 111 |
+
except OSError:
|
| 112 |
+
continue
|
| 113 |
+
seen_paths.add(rel)
|
| 114 |
+
results.append(
|
| 115 |
+
LocalImageEntry(
|
| 116 |
+
path=rel,
|
| 117 |
+
root=r,
|
| 118 |
+
year=y,
|
| 119 |
+
filename=path.name,
|
| 120 |
+
file_size_bytes=size,
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
return results
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def entry_to_dict(entry: LocalImageEntry, include_meta: bool = False) -> dict:
|
| 127 |
+
encoded_path = quote(entry.path, safe="/")
|
| 128 |
+
out = {
|
| 129 |
+
"path": entry.path,
|
| 130 |
+
"year": entry.year,
|
| 131 |
+
"filename": entry.filename,
|
| 132 |
+
"fileSizeBytes": entry.file_size_bytes,
|
| 133 |
+
"thumbUrl": f"/api/dda/local/thumb?path={encoded_path}",
|
| 134 |
+
"source": "local_folder",
|
| 135 |
+
"rootPath": str(entry.root),
|
| 136 |
+
}
|
| 137 |
+
if include_meta:
|
| 138 |
+
try:
|
| 139 |
+
full = safe_resolve(entry.path)
|
| 140 |
+
meta = inspect_image(full)
|
| 141 |
+
out.update({
|
| 142 |
+
"width": meta.width,
|
| 143 |
+
"height": meta.height,
|
| 144 |
+
"hasGeoref": meta.has_georef,
|
| 145 |
+
"format": meta.format,
|
| 146 |
+
})
|
| 147 |
+
except Exception as exc:
|
| 148 |
+
logger.warning("Metadata read failed for %s: %s", entry.path, exc)
|
| 149 |
+
return out
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def thumb_cache_path(relative_path: str) -> Path:
|
| 153 |
+
key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:32]
|
| 154 |
+
return LOCAL_THUMB_CACHE / f"{key}.png"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def get_or_build_thumb(relative_path: str, max_side: int = 256) -> Path:
|
| 158 |
+
full = safe_resolve(relative_path)
|
| 159 |
+
cache = thumb_cache_path(relative_path)
|
| 160 |
+
try:
|
| 161 |
+
if cache.exists() and cache.stat().st_mtime >= full.stat().st_mtime:
|
| 162 |
+
return cache
|
| 163 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
raster_to_preview_png(full, cache, max_side=max_side)
|
| 165 |
+
return cache
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
logger.warning("Thumb build failed for %s: %s", relative_path, exc)
|
| 168 |
+
write_placeholder_png(cache, Path(relative_path).name, max_side)
|
| 169 |
+
return cache
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def ensure_root() -> None:
|
| 173 |
+
for root in get_library_roots():
|
| 174 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
LOCAL_THUMB_CACHE.mkdir(parents=True, exist_ok=True)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def library_debug_info() -> dict:
|
| 179 |
+
"""Diagnostics for troubleshooting missing images."""
|
| 180 |
+
roots_info = []
|
| 181 |
+
for root in get_library_roots():
|
| 182 |
+
info = {
|
| 183 |
+
"path": str(root),
|
| 184 |
+
"exists": root.exists(),
|
| 185 |
+
"years": [],
|
| 186 |
+
"otherFiles": [],
|
| 187 |
+
}
|
| 188 |
+
if root.exists():
|
| 189 |
+
for entry in sorted(root.iterdir()):
|
| 190 |
+
if entry.is_dir() and _is_year_dir(entry.name):
|
| 191 |
+
files = []
|
| 192 |
+
for _, _, p in _iter_image_files(root, year=int(entry.name)):
|
| 193 |
+
files.append({"name": p.name, "size": p.stat().st_size})
|
| 194 |
+
info["years"].append({"year": entry.name, "files": files})
|
| 195 |
+
elif entry.is_file() and entry.name not in ("README.md", ".gitkeep"):
|
| 196 |
+
info["otherFiles"].append(entry.name)
|
| 197 |
+
roots_info.append(info)
|
| 198 |
+
return {
|
| 199 |
+
"roots": roots_info,
|
| 200 |
+
"allowedExtensions": sorted(ALLOWED_EXTENSIONS),
|
| 201 |
+
"totalImages": len(scan_images()),
|
| 202 |
+
}
|
app/dda/local_routes.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slim local helpers: thumb, resolve, detect β backed by tree library."""
|
| 2 |
+
import logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from urllib.parse import unquote
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
| 8 |
+
from fastapi.responses import FileResponse, Response
|
| 9 |
+
from PIL import Image
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from ..database import get_db
|
| 14 |
+
from ..models import User
|
| 15 |
+
from .dda_auth import current_dda_user
|
| 16 |
+
from .detect_service import run_detection_and_save
|
| 17 |
+
from .geotiff_io import load_rgb_pil
|
| 18 |
+
from .rbac.permissions import require_module_permission, user_can
|
| 19 |
+
|
| 20 |
+
from .config import (
|
| 21 |
+
IS_DDA_MODE,
|
| 22 |
+
MAX_GEOTIFF_BYTES,
|
| 23 |
+
MAX_IMAGE_BYTES,
|
| 24 |
+
geotiff_io_available,
|
| 25 |
+
get_detection_max_side,
|
| 26 |
+
get_library_roots,
|
| 27 |
+
get_storage_root,
|
| 28 |
+
is_hf_hosted,
|
| 29 |
+
)
|
| 30 |
+
from .tree.image_service import (
|
| 31 |
+
count_library_images,
|
| 32 |
+
delete_library_image,
|
| 33 |
+
get_or_build_preview,
|
| 34 |
+
get_or_build_thumb,
|
| 35 |
+
list_all_images,
|
| 36 |
+
)
|
| 37 |
+
from .tree.path_service import resolve_file
|
| 38 |
+
from .tree.tree_service import build_tree
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
router = APIRouter()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class LocalImageDeleteBody(BaseModel):
|
| 45 |
+
path: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _normalize_library_path(path: str) -> str:
|
| 49 |
+
return unquote((path or "").replace("\\", "/").strip().lstrip("/"))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _delete_image_by_path(db: Session, user: User, rel: str) -> dict:
|
| 53 |
+
if not user_can(user, db, "library", "delete"):
|
| 54 |
+
raise HTTPException(status_code=403, detail="Missing 'delete' permission on 'library'.")
|
| 55 |
+
if not rel:
|
| 56 |
+
raise HTTPException(status_code=400, detail="path is required")
|
| 57 |
+
result = delete_library_image(
|
| 58 |
+
db, relative_path=rel, action_by=user.email or str(user.id),
|
| 59 |
+
)
|
| 60 |
+
return {"ok": True, **result}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _require_dda():
|
| 64 |
+
if not IS_DDA_MODE:
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=404,
|
| 67 |
+
detail="DDA mode is not enabled. On HF dev Space this is automatic; locally use: python run.py",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def safe_resolve(relative_path: str) -> Path:
|
| 72 |
+
try:
|
| 73 |
+
return resolve_file(relative_path)
|
| 74 |
+
except FileNotFoundError:
|
| 75 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 76 |
+
except ValueError as exc:
|
| 77 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.get("/local/config")
|
| 81 |
+
def local_library_config(user: User = Depends(current_dda_user)):
|
| 82 |
+
_require_dda()
|
| 83 |
+
storage = str(get_storage_root())
|
| 84 |
+
hosted = is_hf_hosted()
|
| 85 |
+
instructions = (
|
| 86 |
+
"Select a node in the tree, choose image type, and upload. "
|
| 87 |
+
"Files are stored under {zone}/{area}/β¦/Images/ in persistent storage."
|
| 88 |
+
if hosted
|
| 89 |
+
else "Use the tree library to organize images by zone/area/year, or upload via the form."
|
| 90 |
+
)
|
| 91 |
+
return {
|
| 92 |
+
"source": "tree_library",
|
| 93 |
+
"isHosted": hosted,
|
| 94 |
+
"spaceId": __import__("os").environ.get("SPACE_ID", ""),
|
| 95 |
+
"appMode": "dda" if IS_DDA_MODE else "legacy",
|
| 96 |
+
"rootPath": storage,
|
| 97 |
+
"rootPaths": [str(r) for r in get_library_roots()],
|
| 98 |
+
"writablePath": storage,
|
| 99 |
+
"storageRoot": storage,
|
| 100 |
+
"instructions": instructions,
|
| 101 |
+
"geotiffEnabled": geotiff_io_available(),
|
| 102 |
+
"detectionMaxSide": get_detection_max_side(),
|
| 103 |
+
"maxGeotiffMb": MAX_GEOTIFF_BYTES // (1024 * 1024),
|
| 104 |
+
"maxGeotiffBytes": MAX_GEOTIFF_BYTES,
|
| 105 |
+
"maxImageMb": MAX_IMAGE_BYTES // (1024 * 1024),
|
| 106 |
+
"maxImageBytes": MAX_IMAGE_BYTES,
|
| 107 |
+
"maxUploadGb": round(MAX_GEOTIFF_BYTES / (1024 ** 3), 2),
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("/local/images")
|
| 112 |
+
def local_images(
|
| 113 |
+
node_id: Optional[int] = Query(None),
|
| 114 |
+
q: Optional[str] = Query(None),
|
| 115 |
+
limit: Optional[int] = Query(None, ge=1, le=500),
|
| 116 |
+
offset: int = Query(0, ge=0),
|
| 117 |
+
db: Session = Depends(get_db),
|
| 118 |
+
user: User = Depends(current_dda_user),
|
| 119 |
+
):
|
| 120 |
+
_require_dda()
|
| 121 |
+
return list_all_images(
|
| 122 |
+
db,
|
| 123 |
+
node_id=node_id,
|
| 124 |
+
query=q,
|
| 125 |
+
include_bounds=False,
|
| 126 |
+
slim=True,
|
| 127 |
+
limit=limit,
|
| 128 |
+
offset=offset,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.get("/local/area-groups")
|
| 133 |
+
def local_area_groups(
|
| 134 |
+
node_id: Optional[int] = Query(None),
|
| 135 |
+
db: Session = Depends(get_db),
|
| 136 |
+
user: User = Depends(current_dda_user),
|
| 137 |
+
):
|
| 138 |
+
"""Images covering the same place, with oldest β Before and newest β After."""
|
| 139 |
+
_require_dda()
|
| 140 |
+
from .tree.area_groups import filter_area_groups_for_node, load_cached_area_groups
|
| 141 |
+
from .tree.tree_service import get_node_or_404
|
| 142 |
+
|
| 143 |
+
payload = load_cached_area_groups(db)
|
| 144 |
+
if node_id:
|
| 145 |
+
node = get_node_or_404(db, node_id)
|
| 146 |
+
return filter_area_groups_for_node(payload, node)
|
| 147 |
+
return payload
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.get("/local/thumb")
|
| 151 |
+
def local_thumb(path: str = Query(...), user: User = Depends(current_dda_user)):
|
| 152 |
+
_require_dda()
|
| 153 |
+
path = _normalize_library_path(path)
|
| 154 |
+
try:
|
| 155 |
+
thumb = get_or_build_thumb(path)
|
| 156 |
+
return FileResponse(
|
| 157 |
+
thumb,
|
| 158 |
+
media_type="image/png",
|
| 159 |
+
headers={"Cache-Control": "private, max-age=86400"},
|
| 160 |
+
)
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
logger.warning("Thumb endpoint fallback for %s: %s", path, exc)
|
| 163 |
+
from .config import LOCAL_THUMB_CACHE
|
| 164 |
+
from .geotiff_io import write_placeholder_png
|
| 165 |
+
|
| 166 |
+
cache = LOCAL_THUMB_CACHE / "fallback.png"
|
| 167 |
+
write_placeholder_png(cache, Path(path).name)
|
| 168 |
+
return FileResponse(cache, media_type="image/png")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/local/preview")
|
| 172 |
+
def local_preview(
|
| 173 |
+
path: str = Query(...),
|
| 174 |
+
max: int = Query(1600, ge=256, le=4096),
|
| 175 |
+
user: User = Depends(current_dda_user),
|
| 176 |
+
):
|
| 177 |
+
"""Full-size preview for library image viewer (cached PNG)."""
|
| 178 |
+
_require_dda()
|
| 179 |
+
path = _normalize_library_path(path)
|
| 180 |
+
try:
|
| 181 |
+
preview = get_or_build_preview(path, max_side=max)
|
| 182 |
+
return FileResponse(
|
| 183 |
+
preview,
|
| 184 |
+
media_type="image/png",
|
| 185 |
+
headers={"Cache-Control": "private, max-age=86400"},
|
| 186 |
+
)
|
| 187 |
+
except FileNotFoundError:
|
| 188 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 189 |
+
except ValueError as exc:
|
| 190 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 191 |
+
except Exception as exc:
|
| 192 |
+
logger.warning("Preview endpoint failed for %s: %s", path, exc)
|
| 193 |
+
raise HTTPException(status_code=500, detail=f"Could not build preview: {exc}") from exc
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@router.get("/local/map-basemaps")
|
| 197 |
+
def local_map_basemaps(user: User = Depends(current_dda_user)):
|
| 198 |
+
"""XYZ basemap presets (OSM, Google Satellite, custom URL)."""
|
| 199 |
+
_require_dda()
|
| 200 |
+
from .map_tiles import BASEMAP_PRESETS
|
| 201 |
+
return {"basemaps": BASEMAP_PRESETS}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@router.get("/local/map-info")
|
| 205 |
+
def local_map_info(
|
| 206 |
+
path: str = Query(...),
|
| 207 |
+
db: Session = Depends(get_db),
|
| 208 |
+
user: User = Depends(current_dda_user),
|
| 209 |
+
):
|
| 210 |
+
"""WGS84 bounds + tile URL for overlaying a library raster on a web map."""
|
| 211 |
+
_require_dda()
|
| 212 |
+
path = _normalize_library_path(path)
|
| 213 |
+
try:
|
| 214 |
+
from .map_tiles import build_map_info
|
| 215 |
+
return build_map_info(path, db=db)
|
| 216 |
+
except FileNotFoundError:
|
| 217 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 218 |
+
except ValueError as exc:
|
| 219 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
logger.warning("map-info failed for %s: %s", path, exc)
|
| 222 |
+
raise HTTPException(status_code=500, detail=f"Could not read georef: {exc}") from exc
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.get("/local/map-tiles/{z}/{x}/{y}.png")
|
| 226 |
+
def local_map_tile(
|
| 227 |
+
z: int,
|
| 228 |
+
x: int,
|
| 229 |
+
y: int,
|
| 230 |
+
path: str = Query(...),
|
| 231 |
+
user: User = Depends(current_dda_user),
|
| 232 |
+
):
|
| 233 |
+
"""XYZ tile of a georeferenced GeoTIFF warped to Web Mercator."""
|
| 234 |
+
_require_dda()
|
| 235 |
+
path = _normalize_library_path(path)
|
| 236 |
+
try:
|
| 237 |
+
from .map_tiles import render_xyz_tile
|
| 238 |
+
png = render_xyz_tile(path, z, x, y)
|
| 239 |
+
return Response(
|
| 240 |
+
content=png,
|
| 241 |
+
media_type="image/png",
|
| 242 |
+
headers={"Cache-Control": "public, max-age=86400"},
|
| 243 |
+
)
|
| 244 |
+
except FileNotFoundError:
|
| 245 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 246 |
+
except ValueError as exc:
|
| 247 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.get("/local/map-overview")
|
| 251 |
+
def local_map_overview(
|
| 252 |
+
path: str = Query(...),
|
| 253 |
+
max: int = Query(2048, ge=256, le=4096),
|
| 254 |
+
user: User = Depends(current_dda_user),
|
| 255 |
+
):
|
| 256 |
+
"""Web-Mercator overview PNG for distortion-free overlay on XYZ basemaps."""
|
| 257 |
+
_require_dda()
|
| 258 |
+
path = _normalize_library_path(path)
|
| 259 |
+
try:
|
| 260 |
+
from .map_tiles import render_mercator_overview
|
| 261 |
+
png = render_mercator_overview(path, max_side=max)
|
| 262 |
+
return Response(
|
| 263 |
+
content=png,
|
| 264 |
+
media_type="image/png",
|
| 265 |
+
headers={"Cache-Control": "public, max-age=86400"},
|
| 266 |
+
)
|
| 267 |
+
except FileNotFoundError:
|
| 268 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 269 |
+
except ValueError as exc:
|
| 270 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 271 |
+
except Exception as exc:
|
| 272 |
+
logger.warning("map-overview failed for %s: %s", path, exc)
|
| 273 |
+
raise HTTPException(status_code=500, detail=f"Could not build overlay: {exc}") from exc
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@router.post("/local/images/delete")
|
| 277 |
+
def local_delete_image_post(
|
| 278 |
+
body: LocalImageDeleteBody,
|
| 279 |
+
db: Session = Depends(get_db),
|
| 280 |
+
user: User = Depends(current_dda_user),
|
| 281 |
+
):
|
| 282 |
+
"""Delete a library image (preferred β works reliably across browsers)."""
|
| 283 |
+
_require_dda()
|
| 284 |
+
return _delete_image_by_path(db, user, _normalize_library_path(body.path))
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.delete("/local/images")
|
| 288 |
+
def local_delete_image(
|
| 289 |
+
body: Optional[LocalImageDeleteBody] = None,
|
| 290 |
+
path: Optional[str] = Query(None),
|
| 291 |
+
db: Session = Depends(get_db),
|
| 292 |
+
user: User = Depends(current_dda_user),
|
| 293 |
+
):
|
| 294 |
+
_require_dda()
|
| 295 |
+
rel = _normalize_library_path(body.path if body and body.path else (path or ""))
|
| 296 |
+
return _delete_image_by_path(db, user, rel)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.post("/local/rescan")
|
| 300 |
+
def local_rescan(db: Session = Depends(get_db), user: User = Depends(require_module_permission("library", "edit"))):
|
| 301 |
+
_require_dda()
|
| 302 |
+
from .tree.sync_service import sync_from_filesystem
|
| 303 |
+
|
| 304 |
+
try:
|
| 305 |
+
sync_stats = sync_from_filesystem(db, check_orphans=True)
|
| 306 |
+
except Exception as exc:
|
| 307 |
+
logger.exception("Filesystem rescan failed")
|
| 308 |
+
raise HTTPException(status_code=500, detail=f"Rescan failed: {exc}") from exc
|
| 309 |
+
tree = build_tree(db)
|
| 310 |
+
return {
|
| 311 |
+
"ok": True,
|
| 312 |
+
"tree": tree,
|
| 313 |
+
"totalImages": count_library_images(db),
|
| 314 |
+
"storageRoot": str(get_storage_root()),
|
| 315 |
+
"sync": sync_stats,
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.post("/detect/preflight")
|
| 320 |
+
async def detect_preflight(
|
| 321 |
+
base_path: str = Form(...),
|
| 322 |
+
comparison_path: str = Form(...),
|
| 323 |
+
roi: Optional[str] = Form(None),
|
| 324 |
+
db: Session = Depends(get_db),
|
| 325 |
+
user: User = Depends(require_module_permission("detect", "create")),
|
| 326 |
+
):
|
| 327 |
+
"""Check a library image pair (CRS/GSD/bands/georef/overlap) without running detection.
|
| 328 |
+
|
| 329 |
+
Lets the UI ask "is this pair suitable?" up front and show the user any
|
| 330 |
+
warnings before committing to a multi-minute detection run. ``roi``
|
| 331 |
+
(optional) narrows the overlap check to the selected crop window.
|
| 332 |
+
"""
|
| 333 |
+
_require_dda()
|
| 334 |
+
base_norm = base_path.replace("\\", "/").strip()
|
| 335 |
+
comp_norm = comparison_path.replace("\\", "/").strip()
|
| 336 |
+
if not base_norm or not comp_norm:
|
| 337 |
+
raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
|
| 338 |
+
if base_norm == comp_norm:
|
| 339 |
+
raise HTTPException(status_code=400, detail="Base and comparison images must be different")
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
base_file = safe_resolve(base_norm)
|
| 343 |
+
comp_file = safe_resolve(comp_norm)
|
| 344 |
+
except HTTPException:
|
| 345 |
+
raise
|
| 346 |
+
except Exception as exc:
|
| 347 |
+
raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
|
| 348 |
+
|
| 349 |
+
roi_dict = None
|
| 350 |
+
if roi and roi.strip():
|
| 351 |
+
import json as _json
|
| 352 |
+
|
| 353 |
+
from .geotiff_io import parse_roi
|
| 354 |
+
try:
|
| 355 |
+
roi_dict = parse_roi(_json.loads(roi))
|
| 356 |
+
except (ValueError, _json.JSONDecodeError) as exc:
|
| 357 |
+
raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
|
| 358 |
+
|
| 359 |
+
from .preflight import run_preflight_checks
|
| 360 |
+
preflight = run_preflight_checks(db, base_file, comp_file, base_norm, comp_norm, roi=roi_dict)
|
| 361 |
+
return {
|
| 362 |
+
"hardFail": preflight.hard_fail,
|
| 363 |
+
"failReason": preflight.fail_reason,
|
| 364 |
+
"warnings": preflight.warnings,
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@router.post("/detect/from-library")
|
| 369 |
+
async def detect_from_library(
|
| 370 |
+
request: Request,
|
| 371 |
+
base_path: str = Form(...),
|
| 372 |
+
comparison_path: str = Form(...),
|
| 373 |
+
method: str = Form("AI-Based Deep Learning"),
|
| 374 |
+
title: str = Form("Untitled run"),
|
| 375 |
+
zone: str = Form(""),
|
| 376 |
+
village: str = Form(""),
|
| 377 |
+
enable_registration: bool = Form(True),
|
| 378 |
+
enable_normalization: bool = Form(True),
|
| 379 |
+
detection_sensitivity: float = Form(0.5),
|
| 380 |
+
min_region_area: Optional[int] = Form(150),
|
| 381 |
+
notify_email: Optional[str] = Form(None),
|
| 382 |
+
roi: Optional[str] = Form(None),
|
| 383 |
+
shape_mode: str = Form("polygon"),
|
| 384 |
+
db: Session = Depends(get_db),
|
| 385 |
+
user: User = Depends(require_module_permission("detect", "create")),
|
| 386 |
+
):
|
| 387 |
+
"""Run change detection on two library images by relative path.
|
| 388 |
+
|
| 389 |
+
``roi`` (optional) is a JSON ``{"x","y","w","h"}`` fractional window in
|
| 390 |
+
[0,1]; when present, only that cropped region is detected.
|
| 391 |
+
"""
|
| 392 |
+
_require_dda()
|
| 393 |
+
base_norm = base_path.replace("\\", "/").strip()
|
| 394 |
+
comp_norm = comparison_path.replace("\\", "/").strip()
|
| 395 |
+
if not base_norm or not comp_norm:
|
| 396 |
+
raise HTTPException(status_code=400, detail="base_path and comparison_path are required")
|
| 397 |
+
if base_norm == comp_norm:
|
| 398 |
+
raise HTTPException(status_code=400, detail="Base and comparison images must be different")
|
| 399 |
+
|
| 400 |
+
try:
|
| 401 |
+
base_file = safe_resolve(base_norm)
|
| 402 |
+
comp_file = safe_resolve(comp_norm)
|
| 403 |
+
except HTTPException:
|
| 404 |
+
raise
|
| 405 |
+
except Exception as exc:
|
| 406 |
+
raise HTTPException(status_code=400, detail=f"Invalid library path: {exc}") from exc
|
| 407 |
+
|
| 408 |
+
roi_dict = None
|
| 409 |
+
if roi and roi.strip():
|
| 410 |
+
import json as _json
|
| 411 |
+
|
| 412 |
+
from .geotiff_io import parse_roi
|
| 413 |
+
try:
|
| 414 |
+
roi_dict = parse_roi(_json.loads(roi))
|
| 415 |
+
except (ValueError, _json.JSONDecodeError) as exc:
|
| 416 |
+
raise HTTPException(status_code=400, detail=f"Invalid roi: {exc}") from exc
|
| 417 |
+
|
| 418 |
+
from .preflight import run_preflight_checks
|
| 419 |
+
preflight = run_preflight_checks(db, base_file, comp_file, base_norm, comp_norm, roi=roi_dict)
|
| 420 |
+
if preflight.hard_fail:
|
| 421 |
+
raise HTTPException(status_code=400, detail=preflight.fail_reason)
|
| 422 |
+
|
| 423 |
+
try:
|
| 424 |
+
from ..detection_config import get_load_max_side
|
| 425 |
+
from .geotiff_io import load_rgb_roi
|
| 426 |
+
load_side = get_load_max_side()
|
| 427 |
+
if roi_dict:
|
| 428 |
+
before_pil = load_rgb_roi(base_file, roi_dict, max_side=load_side)
|
| 429 |
+
after_pil = load_rgb_roi(comp_file, roi_dict, max_side=load_side)
|
| 430 |
+
else:
|
| 431 |
+
before_pil = load_rgb_pil(base_file, max_side=load_side)
|
| 432 |
+
after_pil = load_rgb_pil(comp_file, max_side=load_side)
|
| 433 |
+
except RuntimeError as exc:
|
| 434 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 435 |
+
except Exception as exc:
|
| 436 |
+
raise HTTPException(status_code=400, detail=f"Could not load images: {exc}") from exc
|
| 437 |
+
|
| 438 |
+
gsd_debug = None
|
| 439 |
+
try:
|
| 440 |
+
from .geotiff_io import harmonize_pair_gsd
|
| 441 |
+
before_pil, after_pil, gsd_debug = harmonize_pair_gsd(
|
| 442 |
+
before_pil, after_pil, base_file, comp_file)
|
| 443 |
+
except Exception as exc:
|
| 444 |
+
logger.warning("GSD harmonization skipped: %s", exc)
|
| 445 |
+
|
| 446 |
+
if before_pil.size != after_pil.size:
|
| 447 |
+
after_pil = after_pil.resize(before_pil.size, Image.Resampling.LANCZOS)
|
| 448 |
+
|
| 449 |
+
if title == "Untitled run":
|
| 450 |
+
title = f"{Path(base_norm).name} vs {Path(comp_norm).name}"
|
| 451 |
+
|
| 452 |
+
try:
|
| 453 |
+
# Use fullres load cap (up to DETECTION_FULLRES_MAX_SIDE), not the
|
| 454 |
+
# legacy 4096 downscaled cap β otherwise max-res config is ignored.
|
| 455 |
+
detect_max = load_side
|
| 456 |
+
logger.info(
|
| 457 |
+
"Library detection max_size=%d (loaded %s)",
|
| 458 |
+
detect_max, before_pil.size,
|
| 459 |
+
)
|
| 460 |
+
return run_detection_and_save(
|
| 461 |
+
db,
|
| 462 |
+
before_pil,
|
| 463 |
+
after_pil,
|
| 464 |
+
method=method,
|
| 465 |
+
title=title,
|
| 466 |
+
zone=zone,
|
| 467 |
+
village=village,
|
| 468 |
+
enable_registration=enable_registration,
|
| 469 |
+
enable_normalization=enable_normalization,
|
| 470 |
+
detection_sensitivity=detection_sensitivity,
|
| 471 |
+
min_region_area=min_region_area,
|
| 472 |
+
notify_email=notify_email,
|
| 473 |
+
max_size=detect_max,
|
| 474 |
+
geo_bounds_path=base_file,
|
| 475 |
+
comparison_file=comp_file,
|
| 476 |
+
base_path=base_norm,
|
| 477 |
+
comparison_path=comp_norm,
|
| 478 |
+
user_id=user.id,
|
| 479 |
+
gsd_debug=gsd_debug,
|
| 480 |
+
shape_mode=shape_mode,
|
| 481 |
+
preflight_warnings=preflight.warnings,
|
| 482 |
+
)
|
| 483 |
+
except Exception as exc:
|
| 484 |
+
logger.exception("Library detection failed for %s vs %s", base_norm, comp_norm)
|
| 485 |
+
raise HTTPException(status_code=500, detail=f"Detection failed: {exc}") from exc
|
app/dda/logs_routes.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API for the application Logs page."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 5 |
+
from fastapi.responses import FileResponse, PlainTextResponse
|
| 6 |
+
|
| 7 |
+
from ..models import User
|
| 8 |
+
from .applog import clear_logs, log_file_path, query_logs
|
| 9 |
+
from .rbac.permissions import require_module_permission
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _require_dda():
|
| 15 |
+
from .config import IS_DDA_MODE
|
| 16 |
+
if not IS_DDA_MODE:
|
| 17 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/logs")
|
| 21 |
+
def list_logs(
|
| 22 |
+
level: str = Query(""),
|
| 23 |
+
q: str = Query(""),
|
| 24 |
+
logger: str = Query(""),
|
| 25 |
+
limit: int = Query(200, ge=1, le=1000),
|
| 26 |
+
offset: int = Query(0, ge=0),
|
| 27 |
+
user: User = Depends(require_module_permission("logs", "view")),
|
| 28 |
+
):
|
| 29 |
+
_require_dda()
|
| 30 |
+
return query_logs(level=level, q=q, logger_name=logger, limit=limit, offset=offset)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/logs/download")
|
| 34 |
+
def download_logs(user: User = Depends(require_module_permission("logs", "view"))):
|
| 35 |
+
_require_dda()
|
| 36 |
+
path = log_file_path()
|
| 37 |
+
if path.exists() and path.stat().st_size:
|
| 38 |
+
return FileResponse(path, filename="app.log", media_type="text/plain")
|
| 39 |
+
data = query_logs(limit=1000, offset=0)
|
| 40 |
+
body = "\n".join(
|
| 41 |
+
f"{row['ts']} {row['level']} [{row['logger']}] {row['message']}"
|
| 42 |
+
for row in reversed(data["logs"])
|
| 43 |
+
) or "(no log entries yet)"
|
| 44 |
+
return PlainTextResponse(body, headers={"Content-Disposition": 'attachment; filename="app.log"'})
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.delete("/logs")
|
| 48 |
+
def api_clear_logs(user: User = Depends(require_module_permission("logs", "delete"))):
|
| 49 |
+
_require_dda()
|
| 50 |
+
return clear_logs()
|
app/dda/map_tiles.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""XYZ map tiles for georeferenced library rasters (QGIS-style overlay)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import io
|
| 6 |
+
import logging
|
| 7 |
+
import math
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Optional, Tuple
|
| 10 |
+
from urllib.parse import quote
|
| 11 |
+
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from .config import LOCAL_THUMB_CACHE
|
| 15 |
+
from .geotiff_io import inspect_image, read_georef
|
| 16 |
+
from .tree.path_service import resolve_file
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
TILE_SIZE = 256
|
| 21 |
+
MAX_ZOOM = 22
|
| 22 |
+
WEB_MERCATOR_ORIGIN = 20037508.342789244
|
| 23 |
+
_EMPTY_PNG: Optional[bytes] = None
|
| 24 |
+
|
| 25 |
+
BoundsWGS84 = Tuple[float, float, float, float]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def empty_tile_png() -> bytes:
|
| 29 |
+
global _EMPTY_PNG
|
| 30 |
+
if _EMPTY_PNG is None:
|
| 31 |
+
buf = io.BytesIO()
|
| 32 |
+
Image.new("RGBA", (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0)).save(buf, format="PNG")
|
| 33 |
+
_EMPTY_PNG = buf.getvalue()
|
| 34 |
+
return _EMPTY_PNG
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def xyz_bounds_3857(z: int, x: int, y: int) -> Tuple[float, float, float, float]:
|
| 38 |
+
"""Web Mercator bounds of an XYZ tile (minx, miny, maxx, maxy)."""
|
| 39 |
+
n = 2 ** int(z)
|
| 40 |
+
tile_w = (2.0 * WEB_MERCATOR_ORIGIN) / n
|
| 41 |
+
minx = -WEB_MERCATOR_ORIGIN + x * tile_w
|
| 42 |
+
maxx = -WEB_MERCATOR_ORIGIN + (x + 1) * tile_w
|
| 43 |
+
maxy = WEB_MERCATOR_ORIGIN - y * tile_w
|
| 44 |
+
miny = WEB_MERCATOR_ORIGIN - (y + 1) * tile_w
|
| 45 |
+
return minx, miny, maxx, maxy
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _rects_intersect(a: Tuple[float, float, float, float],
|
| 49 |
+
b: Tuple[float, float, float, float]) -> bool:
|
| 50 |
+
return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _max_native_zoom(bounds: Optional[BoundsWGS84], width: int, height: int) -> int:
|
| 54 |
+
"""Leaflet maxNativeZoom from raster GSD (approx, WGS84)."""
|
| 55 |
+
if not bounds or width <= 0 or height <= 0:
|
| 56 |
+
return 18
|
| 57 |
+
west, south, east, north = bounds
|
| 58 |
+
lat = max(-85.0, min(85.0, (south + north) / 2.0))
|
| 59 |
+
# metres per pixel along the longer raster side
|
| 60 |
+
m_per_deg_lat = 111_320.0
|
| 61 |
+
m_per_deg_lng = 111_320.0 * max(0.05, math.cos(math.radians(lat)))
|
| 62 |
+
gsd = min(
|
| 63 |
+
abs(east - west) * m_per_deg_lng / max(width, 1),
|
| 64 |
+
abs(north - south) * m_per_deg_lat / max(height, 1),
|
| 65 |
+
)
|
| 66 |
+
gsd = max(gsd, 0.02)
|
| 67 |
+
mpp_z0 = 156543.03392804097 * math.cos(math.radians(lat))
|
| 68 |
+
z = int(round(math.log2(max(mpp_z0 / gsd, 1.0))))
|
| 69 |
+
return max(12, min(MAX_ZOOM, z))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _bounds_dict(bounds: Optional[BoundsWGS84]) -> Optional[dict]:
|
| 73 |
+
if not bounds:
|
| 74 |
+
return None
|
| 75 |
+
west, south, east, north = bounds
|
| 76 |
+
return {
|
| 77 |
+
"west": west,
|
| 78 |
+
"south": south,
|
| 79 |
+
"east": east,
|
| 80 |
+
"north": north,
|
| 81 |
+
"latLng": [[south, west], [north, east]],
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_map_info(relative_path: str, db=None) -> dict:
|
| 86 |
+
"""Metadata the browser needs to overlay a library raster on XYZ basemaps."""
|
| 87 |
+
rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
|
| 88 |
+
full = resolve_file(rel)
|
| 89 |
+
meta = inspect_image(full)
|
| 90 |
+
georef = read_georef(full)
|
| 91 |
+
bounds = meta.bounds_wgs84
|
| 92 |
+
source = meta.georef_source or "none"
|
| 93 |
+
can_tile = bool(georef and georef.crs and bounds)
|
| 94 |
+
|
| 95 |
+
if not bounds and db is not None:
|
| 96 |
+
from .geo_regions import parse_bounds
|
| 97 |
+
from .tree.image_service import get_image_by_file_path
|
| 98 |
+
|
| 99 |
+
img = get_image_by_file_path(db, rel)
|
| 100 |
+
if img and img.bounds_json:
|
| 101 |
+
parsed = parse_bounds(img.bounds_json)
|
| 102 |
+
if parsed:
|
| 103 |
+
bounds = parsed
|
| 104 |
+
source = "manual"
|
| 105 |
+
can_tile = bool(georef and georef.crs)
|
| 106 |
+
|
| 107 |
+
encoded = quote(rel, safe="/")
|
| 108 |
+
merc_bounds = _mercator_aligned_wgs84(full) if can_tile else None
|
| 109 |
+
overlay_bounds = merc_bounds or bounds
|
| 110 |
+
return {
|
| 111 |
+
"path": rel,
|
| 112 |
+
"filename": Path(rel).name,
|
| 113 |
+
"hasGeoref": bool(overlay_bounds),
|
| 114 |
+
"canTile": can_tile,
|
| 115 |
+
"georefSource": source if overlay_bounds else "none",
|
| 116 |
+
"crs": str(georef.crs) if (georef and georef.crs) else (meta.crs or ""),
|
| 117 |
+
"bounds": _bounds_dict(overlay_bounds),
|
| 118 |
+
"width": meta.width,
|
| 119 |
+
"height": meta.height,
|
| 120 |
+
"maxNativeZoom": _max_native_zoom(overlay_bounds, meta.width, meta.height),
|
| 121 |
+
"tileUrl": f"/api/dda/local/map-tiles/{{z}}/{{x}}/{{y}}.png?path={encoded}",
|
| 122 |
+
"overviewUrl": (
|
| 123 |
+
f"/api/dda/local/map-overview?path={encoded}&max=2048" if can_tile else None
|
| 124 |
+
),
|
| 125 |
+
"previewUrl": f"/api/dda/local/preview?path={encoded}&max=2048",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _tile_cache_path(relative_path: str, src: Path, z: int, x: int, y: int) -> Path:
|
| 130 |
+
key = hashlib.sha256(relative_path.encode("utf-8")).hexdigest()[:24]
|
| 131 |
+
mtime = int(src.stat().st_mtime)
|
| 132 |
+
return (
|
| 133 |
+
LOCAL_THUMB_CACHE
|
| 134 |
+
/ "map-tiles"
|
| 135 |
+
/ f"{key}_{mtime}"
|
| 136 |
+
/ str(z)
|
| 137 |
+
/ str(x)
|
| 138 |
+
/ f"{y}.png"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _to_rgba(data, mask) -> Image.Image:
|
| 143 |
+
import numpy as np
|
| 144 |
+
|
| 145 |
+
count = int(data.shape[0])
|
| 146 |
+
if count == 1:
|
| 147 |
+
rgb = np.stack([data[0], data[0], data[0]])
|
| 148 |
+
else:
|
| 149 |
+
rgb = data[:3]
|
| 150 |
+
rgb = np.transpose(rgb, (1, 2, 0)).astype("float32")
|
| 151 |
+
if data.dtype != np.uint8 or float(np.nanmax(rgb) if rgb.size else 0) > 255:
|
| 152 |
+
lo, hi = np.percentile(rgb, (2, 98)) if rgb.size else (0, 1)
|
| 153 |
+
rgb = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) * 255
|
| 154 |
+
rgb_u8 = np.clip(rgb, 0, 255).astype("uint8")
|
| 155 |
+
if mask is None:
|
| 156 |
+
alpha = np.full(rgb_u8.shape[:2], 255, dtype="uint8")
|
| 157 |
+
else:
|
| 158 |
+
alpha = np.asarray(mask)
|
| 159 |
+
if alpha.ndim == 3:
|
| 160 |
+
alpha = alpha[0]
|
| 161 |
+
if alpha.shape != rgb_u8.shape[:2]:
|
| 162 |
+
alpha = np.full(rgb_u8.shape[:2], 255, dtype="uint8")
|
| 163 |
+
else:
|
| 164 |
+
alpha = np.where(alpha > 0, 255, 0).astype("uint8")
|
| 165 |
+
rgba = np.dstack([rgb_u8, alpha])
|
| 166 |
+
return Image.fromarray(rgba, mode="RGBA")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def render_xyz_tile(relative_path: str, z: int, x: int, y: int) -> bytes:
|
| 170 |
+
"""Warp one Web-Mercator XYZ tile from a georeferenced raster."""
|
| 171 |
+
if z < 0 or z > MAX_ZOOM:
|
| 172 |
+
return empty_tile_png()
|
| 173 |
+
n = 2 ** z
|
| 174 |
+
if x < 0 or y < 0 or x >= n or y >= n:
|
| 175 |
+
return empty_tile_png()
|
| 176 |
+
|
| 177 |
+
rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
|
| 178 |
+
src_path = resolve_file(rel)
|
| 179 |
+
cache = _tile_cache_path(rel, src_path, z, x, y)
|
| 180 |
+
try:
|
| 181 |
+
if cache.exists() and cache.stat().st_mtime >= src_path.stat().st_mtime:
|
| 182 |
+
return cache.read_bytes()
|
| 183 |
+
except OSError:
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
png = _render_tile_uncached(src_path, z, x, y)
|
| 187 |
+
if png == empty_tile_png():
|
| 188 |
+
return png
|
| 189 |
+
try:
|
| 190 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
+
tmp = cache.with_suffix(".tmp.png")
|
| 192 |
+
tmp.write_bytes(png)
|
| 193 |
+
tmp.replace(cache)
|
| 194 |
+
except OSError as exc:
|
| 195 |
+
logger.debug("Map tile cache write skipped: %s", exc)
|
| 196 |
+
return png
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _mercator_aligned_wgs84(src_path: Path) -> Optional[BoundsWGS84]:
|
| 200 |
+
"""WGS84 box of the raster's Web-Mercator envelope (matches Google XYZ tiles)."""
|
| 201 |
+
try:
|
| 202 |
+
import rasterio
|
| 203 |
+
from rasterio.crs import CRS
|
| 204 |
+
from rasterio.warp import transform_bounds
|
| 205 |
+
except ImportError:
|
| 206 |
+
return None
|
| 207 |
+
try:
|
| 208 |
+
with rasterio.open(src_path) as src:
|
| 209 |
+
if src.crs is None:
|
| 210 |
+
return None
|
| 211 |
+
web = CRS.from_epsg(3857)
|
| 212 |
+
wgs = CRS.from_epsg(4326)
|
| 213 |
+
crs = _crs_2d(src.crs)
|
| 214 |
+
minx, miny, maxx, maxy = transform_bounds(crs, web, *src.bounds, densify_pts=21)
|
| 215 |
+
west, south, east, north = transform_bounds(web, wgs, minx, miny, maxx, maxy)
|
| 216 |
+
return (float(west), float(south), float(east), float(north))
|
| 217 |
+
except Exception as exc:
|
| 218 |
+
logger.debug("mercator-aligned bounds failed for %s: %s", src_path.name, exc)
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def render_mercator_overview(relative_path: str, max_side: int = 2048) -> bytes:
|
| 223 |
+
"""North-up Web-Mercator PNG so Leaflet ImageOverlay matches Google tiles."""
|
| 224 |
+
rel = (relative_path or "").replace("\\", "/").strip().lstrip("/")
|
| 225 |
+
src_path = resolve_file(rel)
|
| 226 |
+
max_side = max(256, min(int(max_side), 4096))
|
| 227 |
+
key = hashlib.sha256(f"{rel}:overview:{max_side}".encode("utf-8")).hexdigest()[:24]
|
| 228 |
+
mtime = int(src_path.stat().st_mtime)
|
| 229 |
+
cache = LOCAL_THUMB_CACHE / "map-overview" / f"{key}_{mtime}_{max_side}.png"
|
| 230 |
+
try:
|
| 231 |
+
if cache.exists() and cache.stat().st_mtime >= src_path.stat().st_mtime:
|
| 232 |
+
return cache.read_bytes()
|
| 233 |
+
except OSError:
|
| 234 |
+
pass
|
| 235 |
+
png = _render_mercator_overview_uncached(src_path, max_side)
|
| 236 |
+
try:
|
| 237 |
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
| 238 |
+
tmp = cache.with_suffix(".tmp.png")
|
| 239 |
+
tmp.write_bytes(png)
|
| 240 |
+
tmp.replace(cache)
|
| 241 |
+
except OSError as exc:
|
| 242 |
+
logger.debug("Overview cache write skipped: %s", exc)
|
| 243 |
+
return png
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _render_mercator_overview_uncached(src_path: Path, max_side: int) -> bytes:
|
| 247 |
+
import numpy as np
|
| 248 |
+
import rasterio
|
| 249 |
+
from rasterio.crs import CRS
|
| 250 |
+
from rasterio.enums import Resampling
|
| 251 |
+
from rasterio.transform import from_bounds as affine_from_bounds
|
| 252 |
+
from rasterio.warp import reproject, transform_bounds
|
| 253 |
+
|
| 254 |
+
web = CRS.from_epsg(3857)
|
| 255 |
+
with rasterio.open(src_path) as src:
|
| 256 |
+
if src.crs is None:
|
| 257 |
+
raise ValueError("raster has no CRS")
|
| 258 |
+
src_crs = _crs_2d(src.crs)
|
| 259 |
+
minx, miny, maxx, maxy = transform_bounds(src_crs, web, *src.bounds, densify_pts=21)
|
| 260 |
+
width_m = max(maxx - minx, 1e-6)
|
| 261 |
+
height_m = max(maxy - miny, 1e-6)
|
| 262 |
+
if width_m >= height_m:
|
| 263 |
+
out_w = max_side
|
| 264 |
+
out_h = max(1, int(round(max_side * height_m / width_m)))
|
| 265 |
+
else:
|
| 266 |
+
out_h = max_side
|
| 267 |
+
out_w = max(1, int(round(max_side * width_m / height_m)))
|
| 268 |
+
count = min(3, src.count)
|
| 269 |
+
indexes = list(range(1, count + 1))
|
| 270 |
+
dst_transform = affine_from_bounds(minx, miny, maxx, maxy, out_w, out_h)
|
| 271 |
+
src_nodata = src.nodata
|
| 272 |
+
if src_nodata is not None and 0 <= float(src_nodata) <= 255:
|
| 273 |
+
src_nodata = None
|
| 274 |
+
sentinel = -1.0
|
| 275 |
+
dst = np.full((count, out_h, out_w), sentinel, dtype=np.float32)
|
| 276 |
+
reproject(
|
| 277 |
+
source=rasterio.band(src, indexes),
|
| 278 |
+
destination=dst,
|
| 279 |
+
src_transform=src.transform,
|
| 280 |
+
src_crs=src_crs,
|
| 281 |
+
dst_transform=dst_transform,
|
| 282 |
+
dst_crs=web,
|
| 283 |
+
resampling=Resampling.bilinear,
|
| 284 |
+
src_nodata=src_nodata,
|
| 285 |
+
dst_nodata=sentinel,
|
| 286 |
+
)
|
| 287 |
+
valid = dst[0] != sentinel
|
| 288 |
+
if not np.any(valid):
|
| 289 |
+
return empty_tile_png()
|
| 290 |
+
rgb = dst.copy()
|
| 291 |
+
rgb[:, ~valid] = 0
|
| 292 |
+
mask = np.where(valid, 255, 0).astype(np.uint8)
|
| 293 |
+
img = _to_rgba(rgb, mask)
|
| 294 |
+
buf = io.BytesIO()
|
| 295 |
+
img.save(buf, format="PNG", optimize=False)
|
| 296 |
+
return buf.getvalue()
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _crs_2d(crs):
|
| 300 |
+
try:
|
| 301 |
+
if hasattr(crs, "to_2d"):
|
| 302 |
+
two = crs.to_2d()
|
| 303 |
+
if two:
|
| 304 |
+
return two
|
| 305 |
+
except Exception:
|
| 306 |
+
pass
|
| 307 |
+
return crs
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _render_tile_uncached(src_path: Path, z: int, x: int, y: int) -> bytes:
|
| 311 |
+
try:
|
| 312 |
+
import numpy as np
|
| 313 |
+
import rasterio
|
| 314 |
+
from rasterio.crs import CRS
|
| 315 |
+
from rasterio.enums import Resampling
|
| 316 |
+
from rasterio.transform import from_bounds as affine_from_bounds
|
| 317 |
+
from rasterio.warp import reproject, transform_bounds
|
| 318 |
+
except ImportError:
|
| 319 |
+
logger.warning("rasterio missing β cannot render map tiles")
|
| 320 |
+
return empty_tile_png()
|
| 321 |
+
|
| 322 |
+
minx, miny, maxx, maxy = xyz_bounds_3857(z, x, y)
|
| 323 |
+
web_merc = CRS.from_epsg(3857)
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
with rasterio.open(src_path) as src:
|
| 327 |
+
if src.crs is None:
|
| 328 |
+
return empty_tile_png()
|
| 329 |
+
src_crs = _crs_2d(src.crs)
|
| 330 |
+
try:
|
| 331 |
+
src_3857 = transform_bounds(src_crs, web_merc, *src.bounds, densify_pts=21)
|
| 332 |
+
except Exception:
|
| 333 |
+
try:
|
| 334 |
+
src_3857 = transform_bounds(src.crs, web_merc, *src.bounds, densify_pts=21)
|
| 335 |
+
src_crs = src.crs
|
| 336 |
+
except Exception:
|
| 337 |
+
return empty_tile_png()
|
| 338 |
+
if not _rects_intersect(src_3857, (minx, miny, maxx, maxy)):
|
| 339 |
+
return empty_tile_png()
|
| 340 |
+
|
| 341 |
+
count = min(3, src.count)
|
| 342 |
+
indexes = list(range(1, count + 1))
|
| 343 |
+
dst_transform = affine_from_bounds(minx, miny, maxx, maxy, TILE_SIZE, TILE_SIZE)
|
| 344 |
+
# RGB 0 is valid (shadows/black roofs). Only treat a true nodata
|
| 345 |
+
# that is outside 0β255, otherwise GDAL punches holes in the overlay.
|
| 346 |
+
src_nodata = src.nodata
|
| 347 |
+
if src_nodata is not None and 0 <= float(src_nodata) <= 255:
|
| 348 |
+
src_nodata = None
|
| 349 |
+
sentinel = -1.0
|
| 350 |
+
dst = np.full((count, TILE_SIZE, TILE_SIZE), sentinel, dtype=np.float32)
|
| 351 |
+
reproject(
|
| 352 |
+
source=rasterio.band(src, indexes),
|
| 353 |
+
destination=dst,
|
| 354 |
+
src_transform=src.transform,
|
| 355 |
+
src_crs=src_crs,
|
| 356 |
+
dst_transform=dst_transform,
|
| 357 |
+
dst_crs=web_merc,
|
| 358 |
+
resampling=Resampling.bilinear,
|
| 359 |
+
src_nodata=src_nodata,
|
| 360 |
+
dst_nodata=sentinel,
|
| 361 |
+
)
|
| 362 |
+
valid = dst[0] != sentinel
|
| 363 |
+
if not np.any(valid):
|
| 364 |
+
return empty_tile_png()
|
| 365 |
+
rgb = dst.copy()
|
| 366 |
+
rgb[:, ~valid] = 0
|
| 367 |
+
mask = np.where(valid, 255, 0).astype(np.uint8)
|
| 368 |
+
img = _to_rgba(rgb, mask)
|
| 369 |
+
buf = io.BytesIO()
|
| 370 |
+
img.save(buf, format="PNG", optimize=False)
|
| 371 |
+
return buf.getvalue()
|
| 372 |
+
except Exception as exc:
|
| 373 |
+
logger.warning("Map tile %s z=%s x=%s y=%s failed: %s", src_path.name, z, x, y, exc)
|
| 374 |
+
return empty_tile_png()
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
BASEMAP_PRESETS: list[dict[str, Any]] = [
|
| 378 |
+
{
|
| 379 |
+
"id": "osm",
|
| 380 |
+
"label": "OpenStreetMap",
|
| 381 |
+
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
| 382 |
+
"attribution": "© OpenStreetMap contributors",
|
| 383 |
+
"maxZoom": 19,
|
| 384 |
+
"subdomains": "",
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"id": "google-satellite",
|
| 388 |
+
"label": "Google Satellite",
|
| 389 |
+
"url": "https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
|
| 390 |
+
"attribution": "Imagery © Google",
|
| 391 |
+
"maxZoom": 21,
|
| 392 |
+
"subdomains": "0123",
|
| 393 |
+
},
|
| 394 |
+
{
|
| 395 |
+
"id": "google-hybrid",
|
| 396 |
+
"label": "Google Hybrid",
|
| 397 |
+
"url": "https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
|
| 398 |
+
"attribution": "Imagery © Google",
|
| 399 |
+
"maxZoom": 21,
|
| 400 |
+
"subdomains": "0123",
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"id": "esri-imagery",
|
| 404 |
+
"label": "Esri World Imagery",
|
| 405 |
+
"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
| 406 |
+
"attribution": "Tiles © Esri",
|
| 407 |
+
"maxZoom": 19,
|
| 408 |
+
"subdomains": "",
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"id": "custom-xyz",
|
| 412 |
+
"label": "Custom XYZβ¦",
|
| 413 |
+
"url": "",
|
| 414 |
+
"attribution": "",
|
| 415 |
+
"maxZoom": 22,
|
| 416 |
+
"subdomains": "",
|
| 417 |
+
},
|
| 418 |
+
]
|
app/dda/masters_routes.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Zone / District and Village / Location master data."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from ..database import get_db
|
| 11 |
+
from ..models import User
|
| 12 |
+
from .models import DdaVillage, DdaZone, ImageAsset
|
| 13 |
+
from .rbac.permissions import require_module_permission
|
| 14 |
+
from .seed import seed_delhi_hierarchy
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _require_dda():
|
| 20 |
+
from .config import IS_DDA_MODE
|
| 21 |
+
if not IS_DDA_MODE:
|
| 22 |
+
raise HTTPException(status_code=404, detail="DDA mode is not enabled")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _log_masters_action(db: Session, action: str, user: User, new_value: Optional[dict] = None) -> None:
|
| 26 |
+
try:
|
| 27 |
+
from .dda_auth import get_user_role
|
| 28 |
+
from .tree.audit_service import log_action
|
| 29 |
+
log_action(
|
| 30 |
+
db, action, action_by=getattr(user, "email", "") or "",
|
| 31 |
+
role=get_user_role(db, user), module="masters", new_value=new_value,
|
| 32 |
+
)
|
| 33 |
+
db.commit()
|
| 34 |
+
except Exception:
|
| 35 |
+
import logging
|
| 36 |
+
logging.getLogger(__name__).exception("Failed to write audit log for masters action %s", action)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ZoneBody(BaseModel):
|
| 40 |
+
name: str = Field(..., min_length=1, max_length=128)
|
| 41 |
+
mode: str = Field("admin", max_length=32)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class VillageBody(BaseModel):
|
| 45 |
+
name: str = Field(..., min_length=1, max_length=128)
|
| 46 |
+
zone_id: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _zone_dict(zone: DdaZone, village_count: int = 0) -> dict:
|
| 50 |
+
return {
|
| 51 |
+
"id": zone.id,
|
| 52 |
+
"name": zone.name,
|
| 53 |
+
"mode": zone.mode or "admin",
|
| 54 |
+
"villageCount": village_count,
|
| 55 |
+
"createdAt": zone.created_at.isoformat() if zone.created_at else None,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _village_dict(village: DdaVillage, zone: Optional[DdaZone] = None) -> dict:
|
| 60 |
+
z = zone or village.zone
|
| 61 |
+
return {
|
| 62 |
+
"id": village.id,
|
| 63 |
+
"name": village.name,
|
| 64 |
+
"zoneId": village.zone_id,
|
| 65 |
+
"zoneName": z.name if z else "",
|
| 66 |
+
"createdAt": village.created_at.isoformat() if village.created_at else None,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/masters/zones")
|
| 71 |
+
def list_zones(
|
| 72 |
+
db: Session = Depends(get_db),
|
| 73 |
+
user: User = Depends(require_module_permission("masters", "view")),
|
| 74 |
+
):
|
| 75 |
+
_require_dda()
|
| 76 |
+
seed_delhi_hierarchy(db)
|
| 77 |
+
zones = db.query(DdaZone).order_by(DdaZone.name).all()
|
| 78 |
+
counts = {}
|
| 79 |
+
for v in db.query(DdaVillage).all():
|
| 80 |
+
counts[v.zone_id] = counts.get(v.zone_id, 0) + 1
|
| 81 |
+
return {"zones": [_zone_dict(z, counts.get(z.id, 0)) for z in zones]}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/masters/zones")
|
| 85 |
+
def create_zone(
|
| 86 |
+
body: ZoneBody,
|
| 87 |
+
db: Session = Depends(get_db),
|
| 88 |
+
user: User = Depends(require_module_permission("masters", "create")),
|
| 89 |
+
):
|
| 90 |
+
_require_dda()
|
| 91 |
+
name = body.name.strip()
|
| 92 |
+
if db.query(DdaZone).filter(DdaZone.name == name).first():
|
| 93 |
+
raise HTTPException(status_code=409, detail="A zone with this name already exists.")
|
| 94 |
+
zone = DdaZone(name=name, mode=(body.mode or "admin").strip() or "admin")
|
| 95 |
+
db.add(zone)
|
| 96 |
+
db.commit()
|
| 97 |
+
db.refresh(zone)
|
| 98 |
+
_log_masters_action(db, "create_zone", user, {"zone_id": zone.id, "name": zone.name})
|
| 99 |
+
return {"ok": True, "zone": _zone_dict(zone, 0)}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.put("/masters/zones/{zone_id}")
|
| 103 |
+
def update_zone(
|
| 104 |
+
zone_id: int,
|
| 105 |
+
body: ZoneBody,
|
| 106 |
+
db: Session = Depends(get_db),
|
| 107 |
+
user: User = Depends(require_module_permission("masters", "edit")),
|
| 108 |
+
):
|
| 109 |
+
_require_dda()
|
| 110 |
+
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 111 |
+
if not zone:
|
| 112 |
+
raise HTTPException(status_code=404, detail="Zone not found")
|
| 113 |
+
name = body.name.strip()
|
| 114 |
+
clash = db.query(DdaZone).filter(DdaZone.name == name, DdaZone.id != zone_id).first()
|
| 115 |
+
if clash:
|
| 116 |
+
raise HTTPException(status_code=409, detail="A zone with this name already exists.")
|
| 117 |
+
zone.name = name
|
| 118 |
+
zone.mode = (body.mode or zone.mode or "admin").strip()
|
| 119 |
+
db.commit()
|
| 120 |
+
_log_masters_action(db, "update_zone", user, {"zone_id": zone.id, "name": zone.name})
|
| 121 |
+
count = db.query(DdaVillage).filter(DdaVillage.zone_id == zone.id).count()
|
| 122 |
+
return {"ok": True, "zone": _zone_dict(zone, count)}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.delete("/masters/zones/{zone_id}")
|
| 126 |
+
def delete_zone(
|
| 127 |
+
zone_id: int,
|
| 128 |
+
db: Session = Depends(get_db),
|
| 129 |
+
user: User = Depends(require_module_permission("masters", "delete")),
|
| 130 |
+
):
|
| 131 |
+
_require_dda()
|
| 132 |
+
zone = db.query(DdaZone).filter(DdaZone.id == zone_id).first()
|
| 133 |
+
if not zone:
|
| 134 |
+
raise HTTPException(status_code=404, detail="Zone not found")
|
| 135 |
+
in_use = db.query(ImageAsset).filter(ImageAsset.zone_id == zone_id).count()
|
| 136 |
+
if in_use:
|
| 137 |
+
raise HTTPException(status_code=409, detail="This zone is used by library images and cannot be deleted.")
|
| 138 |
+
zone_name = zone.name
|
| 139 |
+
db.query(DdaVillage).filter(DdaVillage.zone_id == zone_id).delete(synchronize_session=False)
|
| 140 |
+
db.delete(zone)
|
| 141 |
+
db.commit()
|
| 142 |
+
_log_masters_action(db, "delete_zone", user, {"zone_id": zone_id, "name": zone_name})
|
| 143 |
+
return {"ok": True}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@router.get("/masters/villages")
|
| 147 |
+
def list_villages(
|
| 148 |
+
zone_id: Optional[int] = None,
|
| 149 |
+
db: Session = Depends(get_db),
|
| 150 |
+
user: User = Depends(require_module_permission("masters", "view")),
|
| 151 |
+
):
|
| 152 |
+
_require_dda()
|
| 153 |
+
seed_delhi_hierarchy(db)
|
| 154 |
+
q = db.query(DdaVillage).order_by(DdaVillage.name)
|
| 155 |
+
if zone_id:
|
| 156 |
+
q = q.filter(DdaVillage.zone_id == zone_id)
|
| 157 |
+
villages = q.all()
|
| 158 |
+
zones = {z.id: z for z in db.query(DdaZone).all()}
|
| 159 |
+
return {
|
| 160 |
+
"villages": [_village_dict(v, zones.get(v.zone_id)) for v in villages],
|
| 161 |
+
"zones": [_zone_dict(z) for z in sorted(zones.values(), key=lambda z: z.name)],
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@router.post("/masters/villages")
|
| 166 |
+
def create_village(
|
| 167 |
+
body: VillageBody,
|
| 168 |
+
db: Session = Depends(get_db),
|
| 169 |
+
user: User = Depends(require_module_permission("masters", "create")),
|
| 170 |
+
):
|
| 171 |
+
_require_dda()
|
| 172 |
+
zone = db.query(DdaZone).filter(DdaZone.id == body.zone_id).first()
|
| 173 |
+
if not zone:
|
| 174 |
+
raise HTTPException(status_code=404, detail="Zone not found")
|
| 175 |
+
name = body.name.strip()
|
| 176 |
+
clash = (
|
| 177 |
+
db.query(DdaVillage)
|
| 178 |
+
.filter(DdaVillage.zone_id == zone.id, DdaVillage.name == name)
|
| 179 |
+
.first()
|
| 180 |
+
)
|
| 181 |
+
if clash:
|
| 182 |
+
raise HTTPException(status_code=409, detail="That location already exists in this zone.")
|
| 183 |
+
village = DdaVillage(zone_id=zone.id, name=name)
|
| 184 |
+
db.add(village)
|
| 185 |
+
db.commit()
|
| 186 |
+
db.refresh(village)
|
| 187 |
+
_log_masters_action(db, "create_village", user, {"village_id": village.id, "name": village.name, "zone_id": zone.id})
|
| 188 |
+
return {"ok": True, "village": _village_dict(village, zone)}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.put("/masters/villages/{village_id}")
|
| 192 |
+
def update_village(
|
| 193 |
+
village_id: int,
|
| 194 |
+
body: VillageBody,
|
| 195 |
+
db: Session = Depends(get_db),
|
| 196 |
+
user: User = Depends(require_module_permission("masters", "edit")),
|
| 197 |
+
):
|
| 198 |
+
_require_dda()
|
| 199 |
+
village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
|
| 200 |
+
if not village:
|
| 201 |
+
raise HTTPException(status_code=404, detail="Location not found")
|
| 202 |
+
zone = db.query(DdaZone).filter(DdaZone.id == body.zone_id).first()
|
| 203 |
+
if not zone:
|
| 204 |
+
raise HTTPException(status_code=404, detail="Zone not found")
|
| 205 |
+
name = body.name.strip()
|
| 206 |
+
clash = (
|
| 207 |
+
db.query(DdaVillage)
|
| 208 |
+
.filter(
|
| 209 |
+
DdaVillage.zone_id == zone.id,
|
| 210 |
+
DdaVillage.name == name,
|
| 211 |
+
DdaVillage.id != village_id,
|
| 212 |
+
)
|
| 213 |
+
.first()
|
| 214 |
+
)
|
| 215 |
+
if clash:
|
| 216 |
+
raise HTTPException(status_code=409, detail="That location already exists in this zone.")
|
| 217 |
+
village.name = name
|
| 218 |
+
village.zone_id = zone.id
|
| 219 |
+
db.commit()
|
| 220 |
+
_log_masters_action(db, "update_village", user, {"village_id": village.id, "name": village.name, "zone_id": zone.id})
|
| 221 |
+
return {"ok": True, "village": _village_dict(village, zone)}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@router.delete("/masters/villages/{village_id}")
|
| 225 |
+
def delete_village(
|
| 226 |
+
village_id: int,
|
| 227 |
+
db: Session = Depends(get_db),
|
| 228 |
+
user: User = Depends(require_module_permission("masters", "delete")),
|
| 229 |
+
):
|
| 230 |
+
_require_dda()
|
| 231 |
+
village = db.query(DdaVillage).filter(DdaVillage.id == village_id).first()
|
| 232 |
+
if not village:
|
| 233 |
+
raise HTTPException(status_code=404, detail="Location not found")
|
| 234 |
+
in_use = db.query(ImageAsset).filter(ImageAsset.village_id == village_id).count()
|
| 235 |
+
if in_use:
|
| 236 |
+
raise HTTPException(status_code=409, detail="This location is used by library images and cannot be deleted.")
|
| 237 |
+
village_name = village.name
|
| 238 |
+
db.delete(village)
|
| 239 |
+
db.commit()
|
| 240 |
+
_log_masters_action(db, "delete_village", user, {"village_id": village_id, "name": village_name})
|
| 241 |
+
return {"ok": True}
|
app/dda/models.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Text
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
|
| 7 |
+
from ..database import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _utcnow():
|
| 11 |
+
return datetime.now(timezone.utc)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DdaZone(Base):
|
| 15 |
+
__tablename__ = "dda_zones"
|
| 16 |
+
|
| 17 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 18 |
+
name = Column(String(128), unique=True, nullable=False, index=True)
|
| 19 |
+
mode = Column(String(32), default="admin") # admin | grid_parent
|
| 20 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 21 |
+
|
| 22 |
+
villages = relationship("DdaVillage", back_populates="zone", cascade="all, delete-orphan")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class DdaVillage(Base):
|
| 26 |
+
__tablename__ = "dda_villages"
|
| 27 |
+
|
| 28 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 29 |
+
zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=False, index=True)
|
| 30 |
+
name = Column(String(128), nullable=False, index=True)
|
| 31 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 32 |
+
|
| 33 |
+
zone = relationship("DdaZone", back_populates="villages")
|
| 34 |
+
images = relationship("ImageAsset", back_populates="village")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ImageAsset(Base):
|
| 38 |
+
"""Centralized satellite / drone image (FR-01, FR-02)."""
|
| 39 |
+
__tablename__ = "dda_image_assets"
|
| 40 |
+
|
| 41 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 42 |
+
uuid = Column(String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4()), index=True)
|
| 43 |
+
zone_id = Column(Integer, ForeignKey("dda_zones.id"), nullable=True, index=True)
|
| 44 |
+
village_id = Column(Integer, ForeignKey("dda_villages.id"), nullable=True, index=True)
|
| 45 |
+
area_name = Column(String(128), default="")
|
| 46 |
+
year = Column(Integer, nullable=True, index=True)
|
| 47 |
+
grid_id = Column(String(64), default="", index=True)
|
| 48 |
+
|
| 49 |
+
capture_date = Column(Date, nullable=True)
|
| 50 |
+
source = Column(String(32), default="satellite") # satellite | drone
|
| 51 |
+
format = Column(String(32), default="geotiff")
|
| 52 |
+
original_filename = Column(String(255), default="")
|
| 53 |
+
|
| 54 |
+
file_path = Column(String(512), nullable=False)
|
| 55 |
+
thumb_path = Column(String(512), default="")
|
| 56 |
+
preview_path = Column(String(512), default="")
|
| 57 |
+
|
| 58 |
+
crs = Column(String(64), default="")
|
| 59 |
+
bounds_json = Column(Text, default="") # WGS84 [west, south, east, north]
|
| 60 |
+
has_georef = Column(Boolean, default=False)
|
| 61 |
+
manual_location_json = Column(Text, default="")
|
| 62 |
+
|
| 63 |
+
width = Column(Integer, default=0)
|
| 64 |
+
height = Column(Integer, default=0)
|
| 65 |
+
file_size_bytes = Column(Integer, default=0)
|
| 66 |
+
|
| 67 |
+
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 68 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 69 |
+
|
| 70 |
+
zone = relationship("DdaZone")
|
| 71 |
+
village = relationship("DdaVillage", back_populates="images")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class AutoDetectSettings(Base):
|
| 75 |
+
"""Singleton (id=1): automatic queue is opt-in and does not start on launch."""
|
| 76 |
+
__tablename__ = "dda_auto_detect_settings"
|
| 77 |
+
|
| 78 |
+
id = Column(Integer, primary_key=True)
|
| 79 |
+
running = Column(Boolean, default=False)
|
| 80 |
+
interval_days = Column(Integer, default=10)
|
| 81 |
+
run_at = Column(String(8), default="02:00")
|
| 82 |
+
next_run_at = Column(DateTime, nullable=True)
|
| 83 |
+
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class AutoPairSchedule(Base):
|
| 87 |
+
"""Periodic automatic detection for an identified same-area pair."""
|
| 88 |
+
__tablename__ = "dda_auto_pair_schedules"
|
| 89 |
+
|
| 90 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 91 |
+
pair_key = Column(String(64), unique=True, nullable=False, index=True)
|
| 92 |
+
group_label = Column(String(255), default="")
|
| 93 |
+
before_path = Column(String(512), nullable=False)
|
| 94 |
+
after_path = Column(String(512), nullable=False)
|
| 95 |
+
interval_days = Column(Integer, default=10)
|
| 96 |
+
enabled = Column(Boolean, default=True)
|
| 97 |
+
last_enqueued_at = Column(DateTime, nullable=True)
|
| 98 |
+
last_completed_at = Column(DateTime, nullable=True)
|
| 99 |
+
last_job_id = Column(Integer, nullable=True)
|
| 100 |
+
last_run_id = Column(Integer, nullable=True)
|
| 101 |
+
last_error = Column(Text, default="")
|
| 102 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 103 |
+
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class DetectionJob(Base):
|
| 107 |
+
"""Async change detection job (FR-04 skeleton)."""
|
| 108 |
+
__tablename__ = "dda_detection_jobs"
|
| 109 |
+
|
| 110 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 111 |
+
status = Column(String(32), default="queued", index=True) # queued|running|completed|failed
|
| 112 |
+
base_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
|
| 113 |
+
comparison_image_id = Column(Integer, ForeignKey("dda_image_assets.id"), nullable=True)
|
| 114 |
+
method = Column(String(64), default="AI-Based Deep Learning")
|
| 115 |
+
params_json = Column(Text, default="{}")
|
| 116 |
+
run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=True)
|
| 117 |
+
error_message = Column(Text, default="")
|
| 118 |
+
notify_email = Column(String(255), default="")
|
| 119 |
+
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 120 |
+
started_at = Column(DateTime, nullable=True)
|
| 121 |
+
completed_at = Column(DateTime, nullable=True)
|
| 122 |
+
created_at = Column(DateTime, default=_utcnow)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class RegionReview(Base):
|
| 126 |
+
"""Per-region human review state (FR-08)."""
|
| 127 |
+
__tablename__ = "dda_region_reviews"
|
| 128 |
+
|
| 129 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 130 |
+
run_id = Column(Integer, ForeignKey("detection_runs.id"), nullable=False, index=True)
|
| 131 |
+
region_id = Column(Integer, nullable=False, index=True)
|
| 132 |
+
status = Column(String(32), default="pending") # pending|confirmed|false_positive|submitted
|
| 133 |
+
reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 134 |
+
notes = Column(Text, default="")
|
| 135 |
+
reviewed_at = Column(DateTime, nullable=True)
|
| 136 |
+
submitted_at = Column(DateTime, nullable=True)
|
| 137 |
+
created_at = Column(DateTime, default=_utcnow)
|
app/dda/pair_align.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Before/after pair pre-alignment and input-quality guard.
|
| 2 |
+
|
| 3 |
+
This module sits *in front of* the detection engine and answers one question
|
| 4 |
+
that the engine currently assumes rather than checks: **are these two images
|
| 5 |
+
actually a comparable, co-registered pair of the same ground?**
|
| 6 |
+
|
| 7 |
+
Three real-world failure modes motivated it (all observed on drone GeoTIFFs):
|
| 8 |
+
|
| 9 |
+
1. **Identical inputs** β the same file uploaded twice. The engine dutifully
|
| 10 |
+
reports "no change"; the operator reads that as a detection failure. We
|
| 11 |
+
catch it up front with a cheap content hash.
|
| 12 |
+
2. **Different pixel grids** β two georeferenced rasters of the same place at
|
| 13 |
+
different GSD / extent / band count. Naive ``cv2.resize`` to a common shape
|
| 14 |
+
*stretches* rather than *aligns*, so static ground shows up as change. We
|
| 15 |
+
reproject BOTH onto one common grid (their geographic overlap, at the finer
|
| 16 |
+
resolution) so a pixel means the same ground in both.
|
| 17 |
+
3. **Un-registerable frames** β raw (non-orthorectified) frames shot from
|
| 18 |
+
different viewpoints. No 2D transform aligns them (parallax). We can't fix
|
| 19 |
+
that here, but we *measure* it and emit an honest warning instead of a
|
| 20 |
+
silent garbage mask.
|
| 21 |
+
|
| 22 |
+
The engine's detection path is untouched; callers opt in. A CLI is provided so
|
| 23 |
+
the alignment of any pair can be checked without running a full job:
|
| 24 |
+
|
| 25 |
+
python -m app.dda.pair_align before.tif after.tif [--out DIR]
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import hashlib
|
| 30 |
+
import logging
|
| 31 |
+
from dataclasses import asdict, dataclass
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Optional, Tuple
|
| 34 |
+
|
| 35 |
+
import cv2
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Result container
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class PairAlignResult:
|
| 47 |
+
"""Outcome of aligning + assessing a before/after pair.
|
| 48 |
+
|
| 49 |
+
``status`` is the single field a caller should branch on:
|
| 50 |
+
* ``"identical"`` β inputs are the same image; there is nothing to detect.
|
| 51 |
+
* ``"ok"`` β aligned and alignment quality is adequate.
|
| 52 |
+
* ``"low_quality"`` β aligned but residual misalignment is high; detection
|
| 53 |
+
will be unreliable (likely raw/un-orthorectified
|
| 54 |
+
frames). Results should be shown with a warning.
|
| 55 |
+
* ``"error"`` β alignment could not be attempted (see ``message``).
|
| 56 |
+
"""
|
| 57 |
+
status: str
|
| 58 |
+
message: str
|
| 59 |
+
ncc: float = 0.0
|
| 60 |
+
method: str = "none"
|
| 61 |
+
grid: Optional[Tuple[int, int]] = None # (width, height) of aligned output
|
| 62 |
+
overlap_frac: float = 0.0
|
| 63 |
+
|
| 64 |
+
def to_json(self) -> dict:
|
| 65 |
+
d = asdict(self)
|
| 66 |
+
if self.grid is not None:
|
| 67 |
+
d["grid"] = list(self.grid)
|
| 68 |
+
return d
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# 1. Identical-input guard
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
def content_hash(arr: np.ndarray) -> str:
|
| 76 |
+
"""Stable MD5 of raw pixel bytes β cheap identical-pair detector."""
|
| 77 |
+
return hashlib.md5(np.ascontiguousarray(arr).tobytes()).hexdigest()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def are_identical(before: np.ndarray, after: np.ndarray) -> bool:
|
| 81 |
+
"""True when the two arrays are pixel-for-pixel identical."""
|
| 82 |
+
if before.shape != after.shape:
|
| 83 |
+
return False
|
| 84 |
+
return content_hash(before) == content_hash(after)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# 2. Geographic grid alignment (the correct alignment for georeferenced pairs)
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def geo_align_pair(before_path: Path, after_path: Path
|
| 92 |
+
) -> Optional[Tuple[np.ndarray, np.ndarray, float]]:
|
| 93 |
+
"""Reproject both rasters onto one common grid over their geographic overlap.
|
| 94 |
+
|
| 95 |
+
Returns ``(before_rgb, after_rgb, overlap_frac)`` as HxWx3 uint8 arrays on
|
| 96 |
+
an identical grid, or ``None`` when either input is not georeferenced or the
|
| 97 |
+
footprints do not overlap. ``overlap_frac`` is the overlap area as a
|
| 98 |
+
fraction of the *after* footprint β a low value means the two rasters barely
|
| 99 |
+
cover the same ground.
|
| 100 |
+
"""
|
| 101 |
+
try:
|
| 102 |
+
import rasterio
|
| 103 |
+
from rasterio.transform import from_origin
|
| 104 |
+
from rasterio.warp import Resampling, reproject
|
| 105 |
+
except ImportError:
|
| 106 |
+
logger.warning("rasterio unavailable β cannot geo-align pair")
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
b = rasterio.open(str(before_path))
|
| 111 |
+
a = rasterio.open(str(after_path))
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
logger.warning("geo_align_pair: could not open rasters: %s", exc)
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
with b, a:
|
| 117 |
+
if b.crs is None or a.crs is None:
|
| 118 |
+
return None
|
| 119 |
+
# Work in the after image's CRS; transform the before bounds into it.
|
| 120 |
+
try:
|
| 121 |
+
from rasterio.warp import transform_bounds
|
| 122 |
+
b_in_a = transform_bounds(b.crs, a.crs, *b.bounds)
|
| 123 |
+
except Exception:
|
| 124 |
+
b_in_a = b.bounds
|
| 125 |
+
|
| 126 |
+
left = max(b_in_a[0], a.bounds.left)
|
| 127 |
+
bottom = max(b_in_a[1], a.bounds.bottom)
|
| 128 |
+
right = min(b_in_a[2], a.bounds.right)
|
| 129 |
+
top = min(b_in_a[3], a.bounds.top)
|
| 130 |
+
if right <= left or top <= bottom:
|
| 131 |
+
return None # no geographic overlap
|
| 132 |
+
|
| 133 |
+
a_area = (a.bounds.right - a.bounds.left) * (a.bounds.top - a.bounds.bottom)
|
| 134 |
+
overlap_frac = ((right - left) * (top - bottom)) / max(a_area, 1e-9)
|
| 135 |
+
|
| 136 |
+
res_x, res_y = a.res
|
| 137 |
+
width = max(1, int(round((right - left) / res_x)))
|
| 138 |
+
height = max(1, int(round((top - bottom) / res_y)))
|
| 139 |
+
dst_transform = from_origin(left, top, res_x, res_y)
|
| 140 |
+
|
| 141 |
+
def _warp(src) -> np.ndarray:
|
| 142 |
+
out = np.zeros((3, height, width), dtype=np.uint8)
|
| 143 |
+
for i in range(3): # first three bands = RGB; ignore any alpha
|
| 144 |
+
reproject(
|
| 145 |
+
source=rasterio.band(src, i + 1),
|
| 146 |
+
destination=out[i],
|
| 147 |
+
src_transform=src.transform, src_crs=src.crs,
|
| 148 |
+
dst_transform=dst_transform, dst_crs=a.crs,
|
| 149 |
+
resampling=Resampling.bilinear,
|
| 150 |
+
)
|
| 151 |
+
return np.ascontiguousarray(out.transpose(1, 2, 0))
|
| 152 |
+
|
| 153 |
+
return _warp(b), _warp(a), float(overlap_frac)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# 3. Alignment-quality assessment
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
def _ncc(gray1: np.ndarray, gray2: np.ndarray) -> float:
|
| 161 |
+
"""Normalized cross-correlation of two same-size grayscale images."""
|
| 162 |
+
a = gray1.astype(np.float32).ravel()
|
| 163 |
+
b = gray2.astype(np.float32).ravel()
|
| 164 |
+
if a.size != b.size or a.size < 64:
|
| 165 |
+
return 0.0
|
| 166 |
+
c = np.corrcoef(a, b)[0, 1]
|
| 167 |
+
return float(c) if np.isfinite(c) else 0.0
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def assess_alignment(before: np.ndarray, after: np.ndarray,
|
| 171 |
+
ncc_ok: float = 0.45) -> Tuple[str, float]:
|
| 172 |
+
"""Judge whether an already-same-grid pair is well enough aligned to detect.
|
| 173 |
+
|
| 174 |
+
Uses global NCC on the shared region. A well-registered VHR pair β even with
|
| 175 |
+
genuine change present β keeps most of the static scene correlated, so NCC
|
| 176 |
+
stays high; two frames off by rotation/parallax collapse toward zero. Returns
|
| 177 |
+
``(status, ncc)`` where status is ``"ok"`` or ``"low_quality"``.
|
| 178 |
+
"""
|
| 179 |
+
if before.shape != after.shape:
|
| 180 |
+
after = cv2.resize(after, (before.shape[1], before.shape[0]))
|
| 181 |
+
g1 = cv2.cvtColor(before, cv2.COLOR_RGB2GRAY)
|
| 182 |
+
g2 = cv2.cvtColor(after, cv2.COLOR_RGB2GRAY)
|
| 183 |
+
ncc = _ncc(g1, g2)
|
| 184 |
+
return ("ok" if ncc >= ncc_ok else "low_quality"), ncc
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
# Top-level entry point
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
|
| 191 |
+
def prepare_pair(before_path: Path, after_path: Path
|
| 192 |
+
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], PairAlignResult]:
|
| 193 |
+
"""Load, guard, and align a pair for detection.
|
| 194 |
+
|
| 195 |
+
Returns ``(before_rgb, after_rgb, result)``. When ``result.status`` is
|
| 196 |
+
``"identical"`` or ``"error"`` the arrays may be ``None`` β callers should
|
| 197 |
+
surface ``result.message`` to the operator instead of running detection.
|
| 198 |
+
On ``"ok"``/``"low_quality"`` the arrays are on a common grid and ready for
|
| 199 |
+
the engine (detection still runs on ``"low_quality"``, just with a warning).
|
| 200 |
+
"""
|
| 201 |
+
before_path, after_path = Path(before_path), Path(after_path)
|
| 202 |
+
|
| 203 |
+
aligned = geo_align_pair(before_path, after_path)
|
| 204 |
+
if aligned is not None:
|
| 205 |
+
before, after, overlap = aligned
|
| 206 |
+
method = "geo_reproject"
|
| 207 |
+
else:
|
| 208 |
+
# No georeferencing: fall back to a plain load + resize-to-match so the
|
| 209 |
+
# engine's own SIFT/ORB/ECC registration can still take over downstream.
|
| 210 |
+
from .geotiff_io import load_rgb_pil
|
| 211 |
+
before = np.array(load_rgb_pil(before_path))[:, :, :3]
|
| 212 |
+
after = np.array(load_rgb_pil(after_path))[:, :, :3]
|
| 213 |
+
if before.shape != after.shape:
|
| 214 |
+
before = cv2.resize(before, (after.shape[1], after.shape[0]))
|
| 215 |
+
overlap = 1.0
|
| 216 |
+
method = "resize_only"
|
| 217 |
+
|
| 218 |
+
if are_identical(before, after):
|
| 219 |
+
return None, None, PairAlignResult(
|
| 220 |
+
status="identical",
|
| 221 |
+
message=("Before and after are the same image (identical pixels). "
|
| 222 |
+
"There is no change to detect - check that two different "
|
| 223 |
+
"dates were selected."),
|
| 224 |
+
method=method, grid=(before.shape[1], before.shape[0]),
|
| 225 |
+
overlap_frac=overlap,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
status, ncc = assess_alignment(before, after)
|
| 229 |
+
grid = (before.shape[1], before.shape[0])
|
| 230 |
+
if status == "ok":
|
| 231 |
+
msg = f"Pair aligned via {method} (NCC={ncc:.2f}, overlap={overlap:.0%})."
|
| 232 |
+
else:
|
| 233 |
+
msg = (f"Pair is poorly aligned (NCC={ncc:.2f}). The two images do not "
|
| 234 |
+
"register - likely raw drone frames from different viewpoints "
|
| 235 |
+
"rather than orthomosaics on a common grid. Detection will be "
|
| 236 |
+
"unreliable; export both dates as north-up orthomosaics on the "
|
| 237 |
+
"same grid for accurate results.")
|
| 238 |
+
return before, after, PairAlignResult(
|
| 239 |
+
status=status, message=msg, ncc=round(ncc, 4),
|
| 240 |
+
method=method, grid=grid, overlap_frac=round(overlap, 4),
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _main(argv=None) -> int:
|
| 245 |
+
import argparse
|
| 246 |
+
|
| 247 |
+
from PIL import Image
|
| 248 |
+
|
| 249 |
+
ap = argparse.ArgumentParser(description="Check before/after pair alignment.")
|
| 250 |
+
ap.add_argument("before")
|
| 251 |
+
ap.add_argument("after")
|
| 252 |
+
ap.add_argument("--out", help="dir to write aligned before/after PNGs")
|
| 253 |
+
args = ap.parse_args(argv)
|
| 254 |
+
|
| 255 |
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
| 256 |
+
before, after, res = prepare_pair(Path(args.before), Path(args.after))
|
| 257 |
+
print(f"status : {res.status}")
|
| 258 |
+
print(f"message : {res.message}")
|
| 259 |
+
print(f"method : {res.method}")
|
| 260 |
+
print(f"ncc : {res.ncc}")
|
| 261 |
+
print(f"overlap : {res.overlap_frac}")
|
| 262 |
+
print(f"grid (w x h): {res.grid}")
|
| 263 |
+
if args.out and before is not None:
|
| 264 |
+
out = Path(args.out)
|
| 265 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 266 |
+
Image.fromarray(before).save(out / "aligned_before.png")
|
| 267 |
+
Image.fromarray(after).save(out / "aligned_after.png")
|
| 268 |
+
print(f"wrote aligned PNGs to {out}")
|
| 269 |
+
return 0 if res.status in ("ok", "low_quality") else 1
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
if __name__ == "__main__":
|
| 273 |
+
raise SystemExit(_main())
|
app/dda/preflight.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preflight image-pair validation: CRS, GSD, RGB bands, overlap, georeferencing.
|
| 2 |
+
|
| 3 |
+
Runs once per detection request, right after library paths are resolved but
|
| 4 |
+
before either image is pixel-decoded β all checks here are cheap header-only
|
| 5 |
+
reads, so a genuinely unusable pair (unreadable file, unsupported band
|
| 6 |
+
layout, no ground overlap) is rejected in milliseconds instead of after
|
| 7 |
+
minutes of registration + model inference. Lesser issues (grayscale input,
|
| 8 |
+
manual-bounds-only georeferencing, GSD mismatch, weak overlap) don't block
|
| 9 |
+
detection β they're collected as warnings and surfaced on the result instead.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import List, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
from sqlalchemy.orm import Session
|
| 18 |
+
|
| 19 |
+
from ..detection_config import (
|
| 20 |
+
get_gsd_tolerance,
|
| 21 |
+
get_max_aspect_ratio_diff,
|
| 22 |
+
get_min_overlap_hard,
|
| 23 |
+
get_min_overlap_warn,
|
| 24 |
+
)
|
| 25 |
+
from .geo_regions import BoundsWGS84, resolve_geo_context
|
| 26 |
+
from .geotiff_io import inspect_image, read_georef, read_gsd_meters
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
_MODE_BAND_COUNTS = {
|
| 33 |
+
"1": 1, "L": 1, "I": 1, "F": 1,
|
| 34 |
+
"LA": 2,
|
| 35 |
+
"RGB": 3, "YCbCr": 3, "P": 3,
|
| 36 |
+
"RGBA": 4, "CMYK": 4,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
_NON_RGB_BAND_KEYWORDS = ("nir", "infrared", "swir", "thermal", "alpha", "panchromatic")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class CheckResult:
|
| 44 |
+
name: str # readability|bands|georef|crs|gsd|overlap
|
| 45 |
+
status: str # pass|warn|fail
|
| 46 |
+
message: str = ""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class PreflightResult:
|
| 51 |
+
hard_fail: bool = False
|
| 52 |
+
fail_reason: str = ""
|
| 53 |
+
warnings: List[str] = field(default_factory=list)
|
| 54 |
+
checks: List[CheckResult] = field(default_factory=list)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def read_band_count(path: Path) -> Optional[int]:
|
| 58 |
+
"""Raster band count via rasterio, falling back to a Pillow modeβband map."""
|
| 59 |
+
try:
|
| 60 |
+
import rasterio
|
| 61 |
+
with rasterio.open(path) as src:
|
| 62 |
+
return int(src.count)
|
| 63 |
+
except ImportError:
|
| 64 |
+
pass
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.warning("read_band_count rasterio failed for %s: %s", path.name, exc)
|
| 67 |
+
try:
|
| 68 |
+
from PIL import Image
|
| 69 |
+
with Image.open(path) as img:
|
| 70 |
+
return _MODE_BAND_COUNTS.get(img.mode, len(img.getbands()))
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
logger.warning("read_band_count Pillow failed for %s: %s", path.name, exc)
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _rect_overlap_frac(
|
| 77 |
+
before_bounds: Optional[BoundsWGS84],
|
| 78 |
+
after_bounds: Optional[BoundsWGS84],
|
| 79 |
+
) -> Optional[float]:
|
| 80 |
+
"""Intersection area / after-footprint area, as a plain WGS84 rectangle."""
|
| 81 |
+
if not before_bounds or not after_bounds:
|
| 82 |
+
return None
|
| 83 |
+
bw, bs, be, bn = before_bounds
|
| 84 |
+
aw, asf, ae, an = after_bounds
|
| 85 |
+
ix0, iy0 = max(bw, aw), max(bs, asf)
|
| 86 |
+
ix1, iy1 = min(be, ae), min(bn, an)
|
| 87 |
+
inter_w, inter_h = max(0.0, ix1 - ix0), max(0.0, iy1 - iy0)
|
| 88 |
+
inter_area = inter_w * inter_h
|
| 89 |
+
after_area = max(1e-12, abs(ae - aw) * abs(an - asf))
|
| 90 |
+
return inter_area / after_area
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _apply_roi_to_bounds(
|
| 94 |
+
bounds: Optional[BoundsWGS84], roi: Optional[dict],
|
| 95 |
+
) -> Optional[BoundsWGS84]:
|
| 96 |
+
"""Crop a WGS84 rectangle to a fractional {x,y,w,h} ROI window.
|
| 97 |
+
|
| 98 |
+
Mirrors ``load_rgb_roi``'s convention: x/y are the top-left corner and
|
| 99 |
+
y grows downward in the image's own pixel grid, i.e. y=0 is the image's
|
| 100 |
+
top edge (north when north-up). The same fractional window is applied to
|
| 101 |
+
both images by the caller (matching how detection itself crops both
|
| 102 |
+
images to the same fractional ROI), so overlap is checked on the actual
|
| 103 |
+
selected area rather than the full footprint.
|
| 104 |
+
"""
|
| 105 |
+
if not bounds or not roi:
|
| 106 |
+
return bounds
|
| 107 |
+
west, south, east, north = bounds
|
| 108 |
+
x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"]
|
| 109 |
+
roi_west = west + x * (east - west)
|
| 110 |
+
roi_east = west + (x + w) * (east - west)
|
| 111 |
+
roi_north = north - y * (north - south)
|
| 112 |
+
roi_south = north - (y + h) * (north - south)
|
| 113 |
+
return (roi_west, roi_south, roi_east, roi_north)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def read_band_descriptions(path: Path) -> Optional[List[str]]:
|
| 117 |
+
"""First-3-band GDAL band descriptions, when the file has any set."""
|
| 118 |
+
try:
|
| 119 |
+
import rasterio
|
| 120 |
+
with rasterio.open(path) as src:
|
| 121 |
+
return list(src.descriptions[:3])
|
| 122 |
+
except Exception:
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _suspicious_band_order(descriptions: Optional[List[Optional[str]]]) -> bool:
|
| 127 |
+
"""Best-effort hint only β most files carry no band descriptions at all,
|
| 128 |
+
so this stays silent far more often than it fires. There is no reliable
|
| 129 |
+
way to verify true RGB band *order* from metadata alone; this only flags
|
| 130 |
+
the case where a sensor explicitly labelled a leading band as non-visible
|
| 131 |
+
(e.g. NIR), which is worth a second look before trusting on-screen color.
|
| 132 |
+
"""
|
| 133 |
+
if not descriptions:
|
| 134 |
+
return False
|
| 135 |
+
for d in descriptions:
|
| 136 |
+
if not d:
|
| 137 |
+
continue
|
| 138 |
+
low = d.lower()
|
| 139 |
+
if any(kw in low for kw in _NON_RGB_BAND_KEYWORDS):
|
| 140 |
+
return True
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def run_preflight_checks(
|
| 145 |
+
db: Session,
|
| 146 |
+
before_path: Path,
|
| 147 |
+
after_path: Path,
|
| 148 |
+
before_rel: str,
|
| 149 |
+
after_rel: str,
|
| 150 |
+
roi: Optional[dict] = None,
|
| 151 |
+
) -> PreflightResult:
|
| 152 |
+
"""Validate a (before, after) library image pair before decoding pixels.
|
| 153 |
+
|
| 154 |
+
``roi`` (optional fractional ``{x,y,w,h}``) narrows the overlap check to
|
| 155 |
+
the actually-selected crop window instead of the full image footprints,
|
| 156 |
+
matching what detection itself will run on when a ROI is set.
|
| 157 |
+
"""
|
| 158 |
+
result = PreflightResult()
|
| 159 |
+
|
| 160 |
+
# 1. Readability
|
| 161 |
+
before_meta = inspect_image(before_path)
|
| 162 |
+
after_meta = inspect_image(after_path)
|
| 163 |
+
if before_meta.width <= 0 or before_meta.height <= 0:
|
| 164 |
+
result.hard_fail = True
|
| 165 |
+
result.fail_reason = f"Before image could not be read: {before_path.name}"
|
| 166 |
+
result.checks.append(CheckResult("readability", "fail", result.fail_reason))
|
| 167 |
+
return result
|
| 168 |
+
if after_meta.width <= 0 or after_meta.height <= 0:
|
| 169 |
+
result.hard_fail = True
|
| 170 |
+
result.fail_reason = f"After image could not be read: {after_path.name}"
|
| 171 |
+
result.checks.append(CheckResult("readability", "fail", result.fail_reason))
|
| 172 |
+
return result
|
| 173 |
+
result.checks.append(CheckResult("readability", "pass"))
|
| 174 |
+
|
| 175 |
+
# 2. RGB bands
|
| 176 |
+
before_bands = read_band_count(before_path)
|
| 177 |
+
after_bands = read_band_count(after_path)
|
| 178 |
+
for label, bands, path in (
|
| 179 |
+
("Before", before_bands, before_path),
|
| 180 |
+
("After", after_bands, after_path),
|
| 181 |
+
):
|
| 182 |
+
if not bands:
|
| 183 |
+
result.hard_fail = True
|
| 184 |
+
result.fail_reason = f"{label} image band layout could not be determined: {path.name}"
|
| 185 |
+
result.checks.append(CheckResult("bands", "fail", result.fail_reason))
|
| 186 |
+
return result
|
| 187 |
+
if bands == 2:
|
| 188 |
+
result.hard_fail = True
|
| 189 |
+
result.fail_reason = f"{label} image has an unsupported 2-band layout: {path.name}"
|
| 190 |
+
result.checks.append(CheckResult("bands", "fail", result.fail_reason))
|
| 191 |
+
return result
|
| 192 |
+
band_msgs = []
|
| 193 |
+
if before_bands == 1 or after_bands == 1:
|
| 194 |
+
band_msgs.append(
|
| 195 |
+
"One or both images are single-band (grayscale) β detection runs on a tripled grayscale channel.")
|
| 196 |
+
extra_band_labels = [
|
| 197 |
+
label for label, bands in (("Before", before_bands), ("After", after_bands)) if bands > 3
|
| 198 |
+
]
|
| 199 |
+
if extra_band_labels:
|
| 200 |
+
band_msgs.append(
|
| 201 |
+
f"{' and '.join(extra_band_labels)} image has more than 3 bands β "
|
| 202 |
+
"only the first 3 are used for detection.")
|
| 203 |
+
suspicious_labels = [
|
| 204 |
+
label for label, path in (("Before", before_path), ("After", after_path))
|
| 205 |
+
if _suspicious_band_order(read_band_descriptions(path))
|
| 206 |
+
]
|
| 207 |
+
if suspicious_labels:
|
| 208 |
+
band_msgs.append(
|
| 209 |
+
f"{' and '.join(suspicious_labels)} image band descriptions suggest non-RGB content "
|
| 210 |
+
"(e.g. NIR/infrared) in the first bands β verify band order before relying on colors.")
|
| 211 |
+
if band_msgs:
|
| 212 |
+
for msg in band_msgs:
|
| 213 |
+
result.warnings.append(msg)
|
| 214 |
+
result.checks.append(CheckResult("bands", "warn", msg))
|
| 215 |
+
else:
|
| 216 |
+
result.checks.append(CheckResult("bands", "pass"))
|
| 217 |
+
|
| 218 |
+
# 3. Georeferencing (also feeds the CRS/GSD/overlap checks below)
|
| 219 |
+
before_geo = resolve_geo_context(db, before_rel, before_path)
|
| 220 |
+
after_geo = resolve_geo_context(db, after_rel, after_path)
|
| 221 |
+
|
| 222 |
+
if before_geo.source == "none" and after_geo.source == "none":
|
| 223 |
+
# Plain, non-georeferenced photo pair β an already-supported use case
|
| 224 |
+
# (pair_align's resize_only fallback). Skip CRS/GSD/overlap entirely
|
| 225 |
+
# rather than penalize a mode the app deliberately allows.
|
| 226 |
+
result.checks.append(CheckResult(
|
| 227 |
+
"georef", "pass", "Neither image is georeferenced β treated as a plain photo pair."))
|
| 228 |
+
result.checks.append(CheckResult("crs", "pass"))
|
| 229 |
+
result.checks.append(CheckResult("gsd", "pass"))
|
| 230 |
+
# With no georeferencing, overlap can't be checked geographically β but
|
| 231 |
+
# detection still force-resizes "after" to "before"'s pixel dimensions
|
| 232 |
+
# regardless, so a large aspect-ratio gap is worth flagging here instead.
|
| 233 |
+
ratio_b = before_meta.width / before_meta.height if before_meta.height else 0.0
|
| 234 |
+
ratio_a = after_meta.width / after_meta.height if after_meta.height else 0.0
|
| 235 |
+
if ratio_b > 0 and ratio_a > 0:
|
| 236 |
+
rel_diff = abs(ratio_b - ratio_a) / max(ratio_b, ratio_a)
|
| 237 |
+
if rel_diff > get_max_aspect_ratio_diff():
|
| 238 |
+
msg = (
|
| 239 |
+
f"Images have different aspect ratios (before {before_meta.width}x{before_meta.height}, "
|
| 240 |
+
f"after {after_meta.width}x{after_meta.height}) β the after image will be stretched "
|
| 241 |
+
"to match, which may distort content.")
|
| 242 |
+
result.warnings.append(msg)
|
| 243 |
+
result.checks.append(CheckResult("aspect_ratio", "warn", msg))
|
| 244 |
+
else:
|
| 245 |
+
result.checks.append(CheckResult("aspect_ratio", "pass"))
|
| 246 |
+
result.checks.append(CheckResult("overlap", "pass"))
|
| 247 |
+
return result
|
| 248 |
+
|
| 249 |
+
if before_geo.source == "none" or after_geo.source == "none":
|
| 250 |
+
msg = "Only one image is georeferenced β geographic overlap could not be verified."
|
| 251 |
+
result.warnings.append(msg)
|
| 252 |
+
result.checks.append(CheckResult("georef", "warn", msg))
|
| 253 |
+
elif before_geo.source == "manual" or after_geo.source == "manual":
|
| 254 |
+
msg = "One or both images rely on manually-entered bounds rather than embedded georeferencing."
|
| 255 |
+
result.warnings.append(msg)
|
| 256 |
+
result.checks.append(CheckResult("georef", "warn", msg))
|
| 257 |
+
else:
|
| 258 |
+
result.checks.append(CheckResult("georef", "pass"))
|
| 259 |
+
|
| 260 |
+
# 4. CRS β present but unreprojectable is distinguishable from absent
|
| 261 |
+
before_georef = read_georef(before_path)
|
| 262 |
+
after_georef = read_georef(after_path)
|
| 263 |
+
broken_crs = [
|
| 264 |
+
label for label, gi in (("Before", before_georef), ("After", after_georef))
|
| 265 |
+
if gi is not None and gi.crs is not None and gi.bounds_wgs84 is None
|
| 266 |
+
]
|
| 267 |
+
if broken_crs:
|
| 268 |
+
msg = (f"{' and '.join(broken_crs)} image CRS is present but could not be reprojected "
|
| 269 |
+
"to WGS84 β verify the coordinate system.")
|
| 270 |
+
result.warnings.append(msg)
|
| 271 |
+
result.checks.append(CheckResult("crs", "warn", msg))
|
| 272 |
+
else:
|
| 273 |
+
result.checks.append(CheckResult("crs", "pass"))
|
| 274 |
+
|
| 275 |
+
# 5. GSD β never a hard fail, only harmonization is affected downstream
|
| 276 |
+
gsd_b = read_gsd_meters(before_path)
|
| 277 |
+
gsd_a = read_gsd_meters(after_path)
|
| 278 |
+
if gsd_b and gsd_a:
|
| 279 |
+
rel_diff = abs(gsd_b - gsd_a) / max(gsd_b, gsd_a)
|
| 280 |
+
tolerance = get_gsd_tolerance()
|
| 281 |
+
if rel_diff > tolerance:
|
| 282 |
+
msg = (f"Ground sample distance differs by {rel_diff * 100:.0f}% "
|
| 283 |
+
f"(before {gsd_b:.2f} m/px, after {gsd_a:.2f} m/px) β "
|
| 284 |
+
"the finer image will be resampled to match.")
|
| 285 |
+
result.warnings.append(msg)
|
| 286 |
+
result.checks.append(CheckResult("gsd", "warn", msg))
|
| 287 |
+
else:
|
| 288 |
+
result.checks.append(CheckResult("gsd", "pass"))
|
| 289 |
+
else:
|
| 290 |
+
result.checks.append(CheckResult("gsd", "pass"))
|
| 291 |
+
|
| 292 |
+
# 6. Overlap β cropped to the selected ROI when one is set, so the check
|
| 293 |
+
# reflects the area detection will actually run on, not the full images.
|
| 294 |
+
before_bounds = _apply_roi_to_bounds(before_geo.bounds, roi)
|
| 295 |
+
after_bounds = _apply_roi_to_bounds(after_geo.bounds, roi)
|
| 296 |
+
roi_note = " within the selected area" if roi else ""
|
| 297 |
+
overlap_frac = _rect_overlap_frac(before_bounds, after_bounds)
|
| 298 |
+
if overlap_frac is not None:
|
| 299 |
+
hard_min = get_min_overlap_hard()
|
| 300 |
+
warn_min = get_min_overlap_warn()
|
| 301 |
+
if overlap_frac < hard_min:
|
| 302 |
+
result.hard_fail = True
|
| 303 |
+
result.fail_reason = (
|
| 304 |
+
f"Images do not overlap on the ground{roi_note} (overlap {overlap_frac * 100:.1f}% "
|
| 305 |
+
"of the after-image footprint) β not suitable for change detection.")
|
| 306 |
+
result.checks.append(CheckResult("overlap", "fail", result.fail_reason))
|
| 307 |
+
return result
|
| 308 |
+
if overlap_frac < warn_min:
|
| 309 |
+
msg = f"Weak geographic overlap{roi_note} ({overlap_frac * 100:.1f}% of the after-image footprint)."
|
| 310 |
+
result.warnings.append(msg)
|
| 311 |
+
result.checks.append(CheckResult("overlap", "warn", msg))
|
| 312 |
+
else:
|
| 313 |
+
result.checks.append(CheckResult("overlap", "pass"))
|
| 314 |
+
else:
|
| 315 |
+
result.checks.append(CheckResult("overlap", "pass"))
|
| 316 |
+
|
| 317 |
+
return result
|